Sury PPX
ReScript PPX to generate Sury schema from types.
It's 100% opt-in. You can use Sury without ppx.
Table of contents
Install
npm install sury sury-ppx
Then update your rescript.json config:
{
...
+ "bs-dependencies": ["sury"],
+ "ppx-flags": ["sury-ppx/bin"],
}
Basic usage
// 1. Define a type and add @schema attribute
@schema
type rating =
| @as("G") GeneralAudiences
| @as("PG") ParentalGuidanceSuggested
| @as("PG13") ParentalStronglyCautioned
| @as("R") Restricted
@schema
type film = {
@as("Id")
id: float,
@as("Title")
title: string,
@as("Tags")
tags: @s.default([]) array<string>,
@as("Rating")
rating: rating,
@as("Age")
deprecatedAgeRestriction: @s.meta({deprecated: true}) option<int>,
}
// 2. Generated by PPX ⬇️
let ratingSchema = S.union([
S.literal(GeneralAudiences),
S.literal(ParentalGuidanceSuggested),
S.literal(ParentalStronglyCautioned),
S.literal(Restricted),
])
let filmSchema = S.object(s => {
id: s.field("Id", S.float),
title: s.field("Title", S.string),
tags: s.fieldOr("Tags", S.array(S.string), []),
rating: s.field("Rating", ratingSchema),
deprecatedAgeRestriction: s.field("Age", S.option(S.int)->S.meta({deprecated: true})),
})
// 3. Parse data using the schema
// The data is validated and transformed to a convenient format
%raw(`{
"Id": 1,
"Title": "My first film",
"Rating": "R",
"Age": 17
}`)->S.parseOrThrow(filmSchema)
// Ok({
// id: 1.,
// title: "My first film",
// tags: [],
// rating: Restricted,
// deprecatedAgeRestriction: Some(17),
// })
// 4. Transform data back using the same schema
{
id: 2.,
tags: ["Loved"],
title: "Sad & sed",
rating: ParentalStronglyCautioned,
deprecatedAgeRestriction: None,
}->S.decodeOrThrow(~from=filmSchema, ~to=S.unknown)
// Ok(%raw(`{
// "Id": 2,
// "Title": "Sad & sed",
// "Rating": "PG13",
// "Tags": ["Loved"],
// "Age": undefined,
// }`))
// 5. Use schema as a building block for other tools
// For example, create a JSON schema and use it for OpenAPI generation
let filmJSONSchema = filmSchema->S.toJSONSchema
Read more about schema usage in the ReScript Schema for ReScript users documentation.
API reference
@schema
Applies to: type declarations, type signatures
Generates a <type name>Schema value for the type.
@schema
type user = {
name: string,
age: int,
}
// Generated by PPX ⬇️
let userSchema = S.schema(s => {
name: s.matches(S.string),
age: s.matches(S.int),
})
A type that references itself is wrapped in S.recursive:
@schema
type rec node = {
id: string,
children: array<node>,
}
// Generated by PPX ⬇️
let nodeSchema = S.recursive("node", nodeSchema =>
S.schema(s => {
id: s.matches(S.string),
children: s.matches(S.array(nodeSchema)),
})
)
Mutually recursive types work too:
@schema
type rec expr = Num(int) | Block(array<stmt>)
@schema
and stmt = {label: string, body: expr}
A few things to know about mutually recursive groups:
- Every member referenced from another
@schemamember needs@schematoo — or a hand-written<name>Schemabinding earlier in scope. - Inside the group, an identifier like
nodeSchema(for example in an@s.matchespayload) refers to the schema being defined, shadowing any earlier binding with the same name.
Recursive types with type parameters are not supported yet — write those by hand
with S.recursive.
@s.matches(S.t<'value>)
Applies to: type expressions
Uses the provided schema for the type.
@schema
type t = @s.matches(S.uri) string
// Generated by PPX ⬇️
let schema = S.uri
@s.null
Applies to: option type expressions, optional record fields
Makes the schema accept null instead of undefined. The output value is still an option.
@schema
type t = @s.null option<string>
// Generated by PPX ⬇️
let schema = S.nullAsOption(S.string)
// Input: string | null
// Output: option<string>
On an optional record field it replaces S.option. The ? still makes the field optional in ReScript, but the data must have the key set to null:
@schema
type t = {
foo?: @s.null string,
}
// Generated by PPX ⬇️
let schema = S.schema(s => {
foo: ?s.matches(S.nullAsOption(S.string)),
})
// Input: {foo: string | null}
// Output: {foo?: string}
@s.nullable
Applies to: option type expressions, optional record fields
Makes the schema accept both null and undefined. The output value is still an option, which turns back into undefined.
@schema
type t = @s.nullable option<string>
// Generated by PPX ⬇️
let schema = S.nullableAsOption(S.string)
// Input: string | null | undefined
// Output: option<string>
On an optional record field it replaces S.option, so both a missing key and null parse to None:
@schema
type t = {
foo?: @s.nullable string,
}
// Generated by PPX ⬇️
let schema = S.schema(s => {
foo: ?s.matches(S.nullableAsOption(S.string)),
})
// Input: {foo?: string | null}
// Output: {foo?: string}
@s.default('value)
Applies to: type expressions
Falls back to the provided value when the data is missing.
@schema
type t = @s.default("Unknown") string
// Generated by PPX ⬇️
let schema = S.option(S.string)->S.Option.getOr("Unknown")
// Input: string | undefined
// Output: string
Combine it with @s.null to fall back on null instead:
@schema
type t = @s.null @s.default("Unknown") string
// Generated by PPX ⬇️
let schema = S.nullAsOption(S.string)->S.Option.getOr("Unknown")
// Input: string | null
// Output: string
@s.defaultWith(unit => 'value)
Applies to: type expressions
The same as @s.default, but the fallback value is created on every parse. Use it for arrays, objects and other mutable values.
@schema
type t = @s.defaultWith(() => []) array<string>
// Generated by PPX ⬇️
let schema = S.option(S.array(S.string))->S.Option.getOrWith(() => [])
// Input: array<string> | undefined
// Output: array<string>
It combines with
@s.nullthe same way as@s.default
@s.meta(S.meta)
Applies to: type declarations, type expressions
Adds metadata to the generated schema.
@schema
type t = @s.meta({description: "A useful bit of text, if you know what to do with it."}) string
// Generated by PPX ⬇️
let schema = S.string->S.meta({description: "A useful bit of text, if you know what to do with it."})
The metadata is picked up by JSON Schema generation:
schema->S.toJSONSchema
// {
// "type": "string",
// "description": "A useful bit of text, if you know what to do with it."
// }
Read more about S.meta in the Sury documentation.
@s.with(S.t<'value> => S.t<'value>)
Applies to: type declarations, type expressions
Transforms the generated schema with the provided function:
@schema
type t = @s.with(S.trim) string
// Generated by PPX ⬇️
let schema = S.string->S.trim
Use _ to pass extra arguments, and repeat the attribute to chain transforms — they apply in order:
@schema
type t = @s.with(S.trim) @s.with(S.minLength(_, 5)) string
// Generated by PPX ⬇️
let schema = S.string->S.trim->S.minLength(5)
The transform must return a schema of the same type — changing it (e.g. with S.to) is a compile-time error.
Ordering matters against @s.default too — the transform wraps whatever the attributes to its left produced. Written after the default it lands outside of it, written before it lands inside:
@schema
type outside = @s.default("Foo") @s.with(S.trim) string
// Generated by PPX ⬇️ (simplified)
let outsideSchema = S.option(S.string)->S.Option.getOr("Foo")->S.trim
@schema
type inside = @s.with(S.trim) @s.default("Foo") string
// Generated by PPX ⬇️ (simplified)
let insideSchema = S.option(S.string->S.trim)->S.Option.getOr("Foo")
@s.strict, @s.strip, @s.deepStrict, @s.deepStrip, @s.noValidation
Applies to: type declarations, type expressions
Apply the schema modifier of the same name. On a type declaration the modifier wraps the whole schema:
@schema @s.strict
type user = {name: string}
// Generated by PPX ⬇️
let userSchema = S.strict(
S.schema(s => {
name: s.matches(S.string),
}),
)
On a type expression it applies only to that part:
@schema
type post = {
author: @s.strict user,
body: @s.noValidation string,
}
// Generated by PPX ⬇️
let postSchema = S.schema(s => {
author: s.matches(S.strict(userSchema)),
body: s.matches(S.noValidation(S.string, true)),
})
Create a PR if you need more attributes.