- Table of contents
- Install
- Basic usage
- Real-world examples
- API reference
- Refinements
- Transforms
- Preprocess
- Functions on schema
- Error handling
- Global config
npm install rescript-schema
Then add rescript-schema
to bs-dependencies
in your rescript.json
:
{
...
+ "bs-dependencies": ["rescript-schema"],
+ "bsc-flags": ["-open RescriptSchema"],
}
// 1. Define a type
type rating =
| @as("G") GeneralAudiences
| @as("PG") ParentalGuidanceSuggested
| @as("PG13") ParentalStronglyCautioned
| @as("R") Restricted
type film = {
id: float,
title: string,
tags: array<string>,
rating: rating,
deprecatedAgeRestriction: option<int>,
}
// 2. Create a schema
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",
S.union([
S.literal(GeneralAudiences),
S.literal(ParentalGuidanceSuggested),
S.literal(ParentalStronglyCautioned),
S.literal(Restricted),
]),
),
deprecatedAgeRestriction: s.field("Age", S.option(S.int)->S.deprecate("Use rating instead")),
})
// 3. Parse data using the schema
// The data is validated and transformed to a convenient format
{
"Id": 1,
"Title": "My first film",
"Rating": "R",
"Age": 17
}->S.parseOrThrow(filmSchema)
// {
// id: 1.,
// title: "My first film",
// tags: [],
// rating: Restricted,
// deprecatedAgeRestriction: Some(17),
// }
// 4. Convert data back using the same schema
{
id: 2.,
tags: ["Loved"],
title: "Sad & sed",
rating: ParentalStronglyCautioned,
deprecatedAgeRestriction: None,
}->S.reverseConvertOrThrow(filmSchema)
// {
// "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 with rescript-json-schema and use it for OpenAPI generation
let filmJSONSchema = JSONSchema.make(filmSchema)
The library uses eval
to compile the most performant possible code for parsers and serializers. See yourself how good it is 👌
Compiled parser code
(i) => {
if (!i || i.constructor !== Object) {
e[7](i);
}
let v0 = i["Id"],
v1 = i["Title"],
v2 = i["Tags"],
v6 = i["Rating"],
v7 = i["Age"];
if (typeof v0 !== "number" || Number.isNaN(v0)) {
e[0](v0);
}
if (typeof v1 !== "string") {
e[1](v1);
}
if (v2 !== void 0 && !Array.isArray(v2)) {
e[2](v2);
}
if (v2 !== void 0) {
for (let v3 = 0; v3 < v2.length; ++v3) {
let v5 = v2[v3];
try {
if (typeof v5 !== "string") {
e[3](v5);
}
} catch (v4) {
if (v4 && v4.s === s) {
v4.path = '["Tags"]' + '["' + v3 + '"]' + v4.path;
}
throw v4;
}
}
}
if (v6 !== "G") {
if (v6 !== "PG") {
if (v6 !== "PG13") {
if (v6 !== "R") {
e[5](v6);
}
}
}
}
if (
v7 !== void 0 &&
(typeof v7 !== "number" ||
v7 > 2147483647 ||
v7 < -2147483648 ||
v7 % 1 !== 0)
) {
e[6](v7);
}
return {
id: v0,
title: v1,
tags: v2 === void 0 ? e[4] : v2,
rating: v6,
deprecatedAgeRestriction: v7,
};
};
Compiled serializer code
(i) => {
let v0 = i["tags"],
v3 = i["rating"];
if (v3 !== "G") {
if (v3 !== "PG") {
if (v3 !== "PG13") {
if (v3 !== "R") {
e[0](v3);
}
}
}
}
return {
Id: i["id"],
Title: i["title"],
Tags: v0,
Rating: v3,
Age: i["deprecatedAgeRestriction"],
};
};
S.t<string>
let schema = S.string
"Hello World!"->S.parseOrThrow(schema)
// "Hello World!"
The S.string
schema represents a data that is a string. It can be further constrainted with the following utility methods.
rescript-schema includes a handful of string-specific refinements and transforms:
S.string->S.stringMaxLength(5) // String must be 5 or fewer characters long
S.string->S.stringMinLength(5) // String must be 5 or more characters long
S.string->S.stringLength(5) // String must be exactly 5 characters long
S.string->S.email // Invalid email address
S.string->S.url // Invalid url
S.string->S.uuid // Invalid UUID
S.string->S.cuid // Invalid CUID
S.string->S.pattern(%re(`/[0-9]/`)) // Invalid
S.string->S.datetime // Invalid datetime string! Must be UTC
S.string->S.trim // trim whitespaces
⚠️ Validating email addresses is nearly impossible with just code. Different clients and servers accept different things and many diverge from the various specs defining "valid" emails. The ONLY real way to validate an email address is to send a verification email to it and check that the user got it. With that in mind, rescript-schema picks a relatively simple regex that does not cover all cases.
When using built-in refinements, you can provide a custom error message.
S.string->S.stringMinLength(1, ~message="String can't be empty")
S.string->S.stringLength(5, ~message="SMS code should be 5 digits long")
The S.string->S.datetime
function has following UTC validation: no timezone offsets with arbitrary sub-second decimal precision.
let datetimeSchema = S.string->S.datetime
// The datetimeSchema has the type S.t<Date.t>
// String is transformed to the Date.t instance
"2020-01-01T00:00:00Z"->S.parseOrThrow(datetimeSchema) // pass
"2020-01-01T00:00:00.123Z"->S.parseOrThrow(datetimeSchema) // pass
"2020-01-01T00:00:00.123456Z"->S.parseOrThrow(datetimeSchema) // pass (arbitrary precision)
"2020-01-01T00:00:00+02:00"->S.parseOrThrow(datetimeSchema) // fail (no offsets allowed)
S.t<bool>
The S.bool
schema represents a data that is a boolean.
S.t<int>
The S.int
schema represents a data that is an integer.
rescript-schema includes some of int-specific refinements:
S.int->S.intMax(5) // Number must be lower than or equal to 5
S.int->S.intMin(5) // Number must be greater than or equal to 5
S.int->S.port // Invalid port
S.t<float>
The S.float
schema represents a data that is a number.
rescript-schema includes some of float-specific refinements:
S.float->S.floatMax(5) // Number must be lower than or equal to 5
S.float->S.floatMin(5) // Number must be greater than or equal to 5
S.t<bigint>
The S.bigint
schema represents a data that is a BigInt.
S.t<'value> => S.t<option<'value>>
let schema = S.option(S.string)
"Hello World!"->S.parseOrThrow(schema)
// Some("Hello World!")
%raw(`undefined`)->S.parseOrThrow(schema)
// None
The S.option
schema represents a data of a specific type that might be undefined.
(S.t<option<'value>>, 'value) => S.t<'value>
let schema = S.option(S.string)->S.Option.getOr("Hello World!")
%raw(`undefined`)->S.parseOrThrow(schema)
// "Hello World!"
"Goodbye World!"->S.parseOrThrow(schema)
// "Goodbye World!"
The Option.getOr
augments a schema to add transformation logic for default values, which are applied when the input is undefined.
🧠 If you want to set a default value for an object field, there's a more convenient
fieldOr
method onObject.s
type.
(S.t<option<'value>>, () => 'value) => S.t<'value>
let schema = S.option(S.array(S.string))->S.Option.getOrWith(() => ["Hello World!"])
%raw(`undefined`)->S.parseOrThrow(schema)
// ["Hello World!"]
["Goodbye World!"]->S.parseOrThrow(schema)
// ["Goodbye World!"]
Also you can use Option.getOrWith
for lazy evaluation of the default value.
S.t<'value> => S.t<option<'value>>
let schema = S.null(S.string)
"Hello World!"->S.parseOrThrow(schema)
// Some("Hello World!")
%raw(`null`)->S.parseOrThrow(schema)
// None
The S.null
schema represents a data of a specific type that might be null.
🧠 Since
S.null
transforms value intooption
type, you can useOption.getOr
/Option.getOrWith
for it as well.
S.t<'value> => S.t<option<'value>>
let schema = S.nullable(S.string)
"Hello World!"->S.parseOrThrow(schema)
// Some("Hello World!")
%raw(`null`)->S.parseOrThrow(schema)
// None
%raw(`undefined`)->S.parseOrThrow(schema)
// None
The S.nullable
schema represents a data of a specific type that might be null or undefined.
🧠 Since
S.nullable
transforms value intooption
type, you can useOption.getOr
/Option.getOrWith
for it as well.
S.t<unit>
The S.unit
schema is an alias for S.literal()
.
'value => S.t<'value>
let tunaSchema = S.literal("Tuna")
let twelveSchema = S.literal(12)
let importantTimestampSchema = S.literal(1652628345865.)
let truSchema = S.literal(true)
let nullSchema = S.literal(Null.null)
let undefinedSchema = S.literal() // Building block for S.unit
// Uses Number.isNaN to match NaN literals
let nanSchema = S.literal(Float.Constants.nan)->S.to(_ => ()) // For NaN literals I recomment adding S.to to transform it to unit. It's better than having it as a float type
// Supports symbols and BigInt
let symbolSchema = S.literal(Symbol.asyncIterator)
let twobigSchema = S.literal(BigInt.fromInt(2))
// Supports variants and polymorphic variants
let appleSchema = S.literal(#apple)
let noneSchema = S.literal(None)
// Does a deep check for plain objects and arrays
let cliArgsSchema = S.literal(("help", "lint"))
// Supports functions and literally any Js values matching them with the === operator
let fn = () => "foo"
let fnSchema = S.literal(fn)
let weakMap = WeakMap.make()
let weakMapSchema = S.literal(weakMap)
The S.literal
schema enforces that a data matches an exact value during parsing and serializing.
(S.Object.s => 'value) => S.t<'value>
type point = {
x: int,
y: int,
}
// The pointSchema will have the S.t<point> type
let pointSchema = S.object(s => {
x: s.field("x", S.int),
y: s.field("y", S.int),
})
// It can be used both for parsing and serializing
{"x": 1, "y": -4}->S.parseOrThrow(pointSchema)
{x: 1, y: -4}->S.reverseConvertOrThrow(pointSchema)
The object
schema represents an object value, that can be transformed into any ReScript value. Here are some examples:
type user = {
id: int,
name: string,
}
// It will have the S.t<user> type
let schema = S.object(s => {
id: s.field("USER_ID", S.int),
name: s.field("USER_NAME", S.string),
})
{
"USER_ID": 1,
"USER_NAME": "John",
}->S.parseOrThrow(schema)
// {id: 1, name: "John"}
{id: 1, name: "John"}->S.reverseConvertOrThrow(schema)
// {"USER_ID": 1, "USER_NAME": "John"}
// It will have the S.t<{"key1":string,"key2":string}> type
let schema = S.object(s => {
"key1": s.field("key1", S.string),
"key2": s.field("key2", S.string),
})
// It will have the S.t<(int, string)> type
let schema = S.object(s => (s.field("USER_ID", S.int), s.field("USER_NAME", S.string)))
{"USER_ID":1,"USER_NAME":"John"}->S.parseOrThrow(schema)
// (1, "John")
The same schema also works for serializing:
(1, "John")->S.reverseConvertOrThrow(schema)
// {"USER_ID":1,"USER_NAME":"John"}
type shape = Circle({radius: float}) | Square({x: float}) | Triangle({x: float, y: float})
// It will have the S.t<shape> type
let schema = S.object(s => {
s.tag("kind", "circle")
Circle({
radius: s.field("radius", S.float),
})
})
{
"kind": "circle",
"radius": 1,
}->S.parseOrThrow(schema)
// Circle({radius: 1})
For values whose runtime representation matches your schema, you can use the less verbose S.schema
. Under the hood, it'll create the same S.object
schema from the example above.
@tag("kind")
type shape =
| @as("circle") Circle({radius: float})
| @as("square") Square({x: float})
| @as("triangle") Triangle({x: float, y: float})
let schema = S.schema(s => Circle({
radius: s.matches(S.float),
}))
You can use the schema for parsing as well as serializing:
Circle({radius: 1})->S.reverseConvertOrThrow(schema)
// {
// "kind": "circle",
// "radius": 1,
// }
It's possible to spread/flatten an object schema in another object schema, allowing you to reuse schemas in a more powerful way.
type entityData = {
name: option<string>,
age: int,
}
type entity = {
id: string,
...entityData,
}
let entityDataSchema = S.object(s => {
name: s.fieldOr("name", S.string, "Unknown"),
age: s.field("age", S.int),
})
let entitySchema = S.object(s => {
let {name, age} = s.flatten(entityDataSchema)
{
id: s.field("id", S.string),
name,
age,
}
})
A nice way to parse nested fields:
let schema = S.object(s => {
{
id: s.field("id", S.string),
name: s.nested("data").fieldOr("name", S.string, "Unknown")
age: s.nested("data").field("age", S.int),
}
})
The s.nested
returns a complete S.Object.s
context of the nested object, which you can use to define nested schema without any limitations.
It's possible to destructure object field schemas inside of definition. You could also notice it in the s.flatten
example 😁
let entitySchema = S.object(s => {
let {name, age} = s.field("data", entityDataSchema)
{
id: s.field("id", S.string),
name,
age,
}
})
🧠 While the example with
s.flatten
expect an object with the type{id: string, name: option<string>, age: int}
, the example above as well as fors.nested
will expect an object with the type{id: string, data: {name: option<string>, age: int}}
.
S.t<'value> => S.t<'value>
// Represents an object without fields
let schema = S.object(_ => ())->S.strict
{
"someField": "value",
}->S.parseOrThrow(schema)
// throws S.error with the message: `Failed parsing at root. Reason: Encountered disallowed excess key "unknownKey" on an object`
By default rescript-schema silently strips unrecognized keys when parsing objects. You can change the behaviour to disallow unrecognized keys with the S.strict
function.
If you want to change it for all schemas in your app, you can use S.setGlobalConfig
function:
S.setGlobalConfig({
defaultUnknownKeys: Strict,
})
S.t<'value> => S.t<'value>
// Represents an object with any fields
let schema = S.object(_ => ())->S.strip
{
"someField": "value",
}->S.parseOrThrow(schema)
// ()
You can use the S.strip
function to reset a object schema to the default behavior (stripping unrecognized keys).
Both S.strict
and S.strip
are applied for the first level of the object schema. If you want to apply it for all nested schemas, you can use S.deepStrict
and S.deepStrip
functions.
let schema = S.schema(s =>
{
"bar": {
"baz": s.matches(S.string),
}
}
)
schema->S.strict // {"baz": string} will still allow unknown keys
schema->S.deepStrict // {"baz": string} will not allow unknown keys
(S.Schema.s => 'value) => S.t<'value>
It's a helper built on S.literal
, S.object
, and S.tuple
to create schemas for runtime representation of ReScript types conveniently.
@unboxed
type answer =
| Text(string)
| MultiSelect(array<string>)
| Other({value: string, @as("description") maybeDescription: option<string>})
let textSchema = S.schema(s => Text(s.matches(S.string)))
// It'll create the following schema:
// S.string->S.to(string => Text(string))
let multySelectSchema = S.schema(s => MultiSelect(s.matches(S.array(S.string))))
// The same as:
// S.array(S.string)->S.to(array => MultiSelect(array))
let otherSchema = S.schema(s => Other({
value: s.matches(S.string),
maybeDescription: s.matches(S.option(S.string)),
}))
// Creates the schema under the hood:
// S.object(s => Other({
// value: s.field("value", S.string),
// maybeDescription: s.field("description", S.option(S.string)),
// }))
// Notice how the field name /|\ is taken from the type's @as attribute
let tupleExampleSchema = S.schema(s => (#id, s.matches(S.string)))
// The same as:
// S.tuple(s => (s.item(0, S.literal(#id)), s.item(1, S.string)))
🧠 Note that
S.schema
relies on the runtime representation of your type, whileS.object
/S.tuple
are more flexible and require you to describe the schema explicitly.
(S.t<'value>, 'value => 'to) => S.t<'to>
type shape = Circle({radius: float}) | Square({x: float}) | Triangle({x: float, y: float})
// It will have the S.t<shape> type
let schema = S.float->S.to(radius => Circle({radius: radius}))
1->S.parseOrThrow(schema)
// Circle({radius: 1.})
The same schema also works for serializing:
Circle({radius: 1})->S.reverseConvertOrThrow(schema)
// 1
array<S.t<'value>> => S.t<'value>
An union represents a logical OR relationship. You can apply this concept to your schemas with S.union
. This is the best API to use for variants and polymorphic variants.
On validation, the S.union
schema returns the result of the first item that was successfully validated.
🧠 Schemas are not guaranteed to be validated in the order they are passed to
S.union
. They are grouped by the input data type to optimise performance and improve error message. Schemas with unknown data typed validated the last.
// TypeScript type for reference:
// type Shape =
// | { kind: "circle"; radius: number }
// | { kind: "square"; x: number }
// | { kind: "triangle"; x: number; y: number };
type shape = Circle({radius: float}) | Square({x: float}) | Triangle({x: float, y: float})
let shapeSchema = S.union([
S.object(s => {
s.tag("kind", "circle")
Circle({
radius: s.field("radius", S.float),
})
}),
S.object(s => {
s.tag("kind", "square")
Square({
x: s.field("x", S.float),
})
}),
S.object(s => {
s.tag("kind", "triangle")
Triangle({
x: s.field("x", S.float),
y: s.field("y", S.float),
})
}),
])
{
"kind": "circle",
"radius": 1,
}->S.parseOrThrow(shapeSchema)
// Circle({radius: 1.})
Square({x: 2.})->S.reverseConvertOrThrow(shapeSchema)
// {
// "kind": "square",
// "x": 2,
// }
Also, you can describe a schema for a enum-like variant using S.union
together with S.literal
.
type outcome = | @as("win") Win | @as("draw") Draw | @as("loss") Loss
let schema = S.union([
S.literal(Win),
S.literal(Draw),
S.literal(Loss),
])
"draw"->S.parseOrThrow(schema)
// Draw
Also, you can use S.enum
as a shorthand for the use case above.
let schema = S.enum([Win, Draw, Loss])
S.t<'value> => S.t<array<'value>>
let schema = S.array(S.string)
["Hello", "World"]->S.parseOrThrow(schema)
// ["Hello", "World"]
The S.array
schema represents an array of data of a specific type.
rescript-schema includes some of array-specific refinements:
S.array(itemSchema)->S.arrayMaxLength(5) // Array must be 5 or fewer items long
S.array(itemSchema)->S.arrayMinLength(5) // Array must be 5 or more items long
S.array(itemSchema)->S.arrayLength(5) // Array must be exactly 5 items long
S.t<'value> => S.t<list<'value>>
let schema = S.list(S.string)
["Hello", "World"]->S.parseOrThrow(schema)
// list{"Hello", "World"}
The S.list
schema represents an array of data of a specific type which is transformed to ReScript's list data-structure.
(S.Tuple.s => 'value) => S.t<'value>
type point = {
x: int,
y: int,
}
// The pointSchema will have the S.t<point> type
let pointSchema = S.tuple(s => {
s.tag(0, "point")
{
x: s.item(1, S.int),
y: s.item(2, S.int),
}
})
// It can be used both for parsing and serializing
["point", 1, -4]->S.parseOrThrow(pointSchema)
{ x: 1, y: -4 }->S.reverseConvertOrThrow(pointSchema)
The S.tuple
schema represents that a data is an array of a specific length with values each of a specific type.
For short tuples without the need for transformation, there are wrappers over S.tuple
:
(S.t<'v0>, S.t<'v1>, S.t<'v2>) => S.t<('v0, 'v1, 'v2)>
let schema = S.tuple3(S.string, S.int, S.bool)
%raw(`["a", 1, true]`)->S.parseOrThrow(schema)
// ("a", 1, true)
S.t<'value> => S.t<dict<'value>>
let schema = S.dict(S.string)
{
"foo": "bar",
"baz": "qux",
}->S.parseOrThrow(schema)
// dict{foo: "bar", baz: "qux"}
The dict
schema represents a dictionary of data of a specific type.
S.t<unknown>
let schema = S.unknown
"Hello World!"->S.parseOrThrow(schema)
// "Hello World!"
The S.unknown
schema represents any data.
S.t<S.never>
let schema = S.never
%raw(`undefined`)->S.parseOrThrow(schema)
// throws S.error with the message: `Failed parsing at root. Reason: Expected never, received undefined`
The never
schema will fail parsing for every value.
(~validate: bool) => S.t<JSON.t>
let schema = S.json(~validate=true)
`"abc"`->S.parseOrThrow(schema)
// "abc" of type JSON.t
The S.json
schema represents a data that is compatible with JSON.
It accepts a validate
as an argument. If it's true, then the value will be validated as valid JSON; otherwise, it unsafely casts it to the JSON.t
type.
(S.t<'value>, ~space: int=?) => S.t<'value>
let schema = S.jsonString(S.int)
"123"->S.parseOrThrow(schema)
// 123
The S.jsonString
schema represents JSON string containing value of a specific type.
(S.t<'value>, string) => S.t<'value>
Use S.describe
to add a description
property to the resulting schema.
let documentedStringSchema = S.string
->S.describe("A useful bit of text, if you know what to do with it.")
documentedStringSchema->S.description // A useful bit of text…
This can be useful for documenting a field, for example in a JSON Schema using a library like rescript-json-schema
.
(S.t<'value>, string) => S.t<'value>
Use S.deprecate
to add a deprecation
message property to the resulting schema.
let deprecatedString = S.string
->S.deprecate("Will be removed in APIv2")
deprecatedString->S.deprecation // Will be removed in APIv2…
This can be useful for documenting a field, for example in a JSON Schema using a library like rescript-json-schema
.
(S.t<'value>, S.Catch.s<'value> => 'value) => S.t<'value>
Use S.catch
to provide a "catch value" to be returned instead of a parsing error.
let schema = S.float->S.catch(_ => 42.)
5->S.parseOrThrow(schema)
// 5.
"tuna"->S.parseOrThrow(schema)
// 42.
Also, the callback S.catch
receives a catch context as a first argument. It contains the caught error and the initial data provided to the parse function.
let schema = S.float->S.catch(s => {
Console.log(s.error) // The caught error
Console.log(s.input) // The data provided to the parse function
42.
})
Conceptually, this is how rescript-schema processes "catch values":
- The data is parsed using the base schema
- If the parsing fails, the "catch value" is returned
(string, S.s<'output> => customDefinition<'input, 'output>) => t<'output>
You can also define your own custom schema factories that are specific to your application's requirements:
let nullableSchema = innerSchema => {
S.custom("Nullable", _ => {
parser: unknown => {
if unknown === %raw(`undefined`) || unknown === %raw(`null`) {
None
} else {
Some(unknown->S.parseOrThrow(innerSchema))
}
},
serializer: value => {
switch value {
| Some(innerValue) =>
innerValue->S.reverseConvertOrThrow(innerSchema)
| None => %raw(`null`)
}
},
})
}
"Hello world!"->S.parseOrThrow(schema)
// Some("Hello World!")
%raw(`null`)->S.parseOrThrow(schema)
// None
%raw(`undefined`)->S.parseOrThrow(schema)
// None
123->S.parseOrThrow(schema)
// throws S.error with the message: `Failed parsing at root. Reason: Expected string, received 123`
(t<'value> => t<'value>) => t<'value>
You can define a recursive schema in rescript-schema.
type rec node = {
id: string,
children: array<node>,
}
let nodeSchema = S.recursive(nodeSchema => {
S.object(s => {
id: s.field("Id", S.string),
children: s.field("Children", S.array(nodeSchema)),
})
})
{
"Id": "1",
"Children": [
{"Id": "2", "Children": []},
{"Id": "3", "Children": [{"Id": "4", "Children": []}]},
],
}->S.parseOrThrow(nodeSchema)
// {
// id: "1",
// children: [{id: "2", children: []}, {id: "3", children: [{id: "4", children: []}]}],
// }
The same schema works for serializing:
{
id: "1",
children: [{id: "2", children: []}, {id: "3", children: [{id: "4", children: []}]}],
}->S.reverseConvertOrThrow(nodeSchema)
// {
// "Id": "1",
// "Children": [
// {"Id": "2", "Children": []},
// {"Id": "3", "Children": [{"Id": "4", "Children": []}]},
// ],
// }
You can also use asynchronous parser:
let nodeSchema = S.recursive(nodeSchema => {
S.object(s => {
params: s.field("Id", S.string)->S.transform(_ => {asyncParser: id => loadParams(~id)}),
children: s.field("Children", S.array(nodeSchema)),
})
})
One great aspect of the example above is that it uses parallelism to make four requests to check for the existence of nodes.
🧠 Despite supporting recursive schema, passing cyclical data into rescript-schema will cause an infinite loop.
rescript-schema lets you provide custom validation logic via refinements. It's useful to add checks that's not possible to cover with type system. For instance: checking that a number is an integer or that a string is a valid email address.
(S.t<'value>, S.s<'value> => 'value => unit) => S.t<'value>
let shortStringSchema = S.string->S.refine(s => value =>
if value->String.length > 255 {
s.fail("String can't be more than 255 characters")
}
)
The refine function is applied for both parser and serializer.
rescript-schema allows to augment schema with transformation logic, letting you transform value during parsing and serializing. This is most commonly used for mapping value to more convenient data-structures.
(S.t<'input>, S.s<'output> => S.transformDefinition<'input, 'output>) => S.t<'output>
let intToString = schema =>
schema->S.transform(s => {
parser: int => int->Int.toString,
serializer: string =>
switch string->Int.fromString {
| Some(int) => int
| None => s.fail("Can't convert string to int")
},
})
Also, you can have an asynchronous transform:
type user = {
id: string,
name: string,
}
let userSchema =
S.string
->S.uuid
->S.transform(s => {
asyncParser: userId => loadUser(~userId),
serializer: user => user.id,
})
await "1"->S.parseAsyncOrThrow(userSchema)
// {
// id: "1",
// name: "John",
// }
{
id: "1",
name: "John",
}->S.reverseConvertOrThrow(userSchema)
// "1"
Typically rescript-schema operates under a "parse then transform" paradigm. rescript-schema validates the input first, then passes it through a chain of transformation functions.
But sometimes you want to apply some transform to the input before parsing happens. Mostly needed when you build sometimes on top of rescript-schema. A simplified example from rescript-envsafe:
let prepareEnvSchema = S.preprocess(_, s => {
switch s.schema->S.classify {
| Literal(Boolean(_))
| Bool => {
parser: unknown => {
switch unknown->Obj.magic {
| "true"
| "t"
| "1" => true
| "false"
| "f"
| "0" => false
| _ => unknown->Obj.magic
}->Obj.magic
},
}
| Int
| Float
| Literal(Number(_)) => {
parser: unknown => {
if unknown->Js.typeof === "string" {
%raw(`+unknown`)
} else {
unknown
}
},
}
| _ => {}
}
})
🧠 When using preprocess on Union it will be applied to nested schemas separately.
The library provides a bunch of built-in operations that can be used to parse, convert, and assert values.
Parsing means that the input value is validated against the schema and transformed to the expected output type. You can use the following operations to parse values:
Operation | Interface | Description |
---|---|---|
S.parseOrThrow | ('any, S.t<'value>) => 'value |
Parses any value with the schema |
S.parseJsonOrThrow | (Js.Json.t, S.t<'value>) => 'value |
Parses JSON value with the schema |
S.parseJsonStringOrThrow | (string, S.t<'value>) => 'value |
Parses JSON string with the schema |
S.parseAsyncOrThrow | ('any, S.t<'value>) => promise<'value> |
Parses any value with the schema having async transformations |
For advanced users you can only transform to the output type without type validations. But be careful, since the input type is not checked:
Operation | Interface | Description |
---|---|---|
S.convertOrThrow | ('any, S.t<'value>) => 'value |
Converts any value to the output type |
S.convertToJsonOrThrow | ('any, S.t<'value>) => Js.Json.t |
Converts any value to JSON |
S.convertToJsonStringOrThrow | ('any, S.t<'value>) => string |
Converts any value to JSON string |
S.convertAsyncOrThrow | ('any, S.t<'value>) => promise<'value> |
Converts any value to the output type having async transformations |
Note, that in this case only type validations are skipped. If your schema has refinements or transforms, they will be applied.
Also, you can use S.removeTypeValidation
helper to turn off type validations for the schema even when it's used with a parse operation.
More often than converting input to output, you'll need to perform the reversed operation. It's usually called "serializing" or "decoding". The ReScript Schema has a unique mental model and provides an ability to reverse any schema with S.reverse
which you can later use with all possible kinds of operations. But for convinence, there's a few helper functions that can be used to convert output values to the initial format:
Operation | Interface | Description |
---|---|---|
S.reverseConvertOrThrow | ('value, S.t<'value>) => 'any |
Converts schema value to the output type |
S.reverseConvertToJsonOrThrow | ('value, S.t<'value>) => Js.Json.t |
Converts schema value to JSON |
S.reverseConvertToJsonStringOrThrow | ('value, S.t<'value>) => string |
Converts schema value to JSON string |
S.reverseConvertAsyncOrThrow | ('value, S.t<'value>) => promise<'any> |
Converts schema value to the output type having async transformations |
This is literally the same as convert operations applied to the reversed schema.
For some cases you might want to simply assert the input value is valid. For this there's S.assertOrThrow
operation:
Operation | Interface | Description |
---|---|---|
S.assertOrThrow | ('any, S.t<'value>) => () |
Asserts that the input value is valid. Since the operation doesn't return a value, it's 2-3 times faster than parseOrThrow depending on the schema |
All operations either return the output value or raise an exception which you can catch with try/catch
block:
try true->S.parseOrThrow(schema) catch {
| S.Error.Raised(error) => Console.log(error->S.Error.message)
}
(S.t<'value>, ~input: input<'value, 'input>, ~output: output<'value, 'transformedOutput>, ~mode: mode<'transformedOutput, 'output>, ~typeValidation: bool=?) => 'input => 'output
If you want to have the most possible performance, or the built-in operations doesn't cover your specific use case, you can use compile
to create fine-tuned operation functions.
let operation = S.compile(
S.string,
~input=Any,
~output=Assert,
~mode=Async,
)
await operation("Hello world!")
// ()
For example, in the example above we've created an async assert operation, which is not available by default.
You can configure compiled function input
with the following options:
Value
- accepts'value
ofS.t<'value>
and reverses the operationUnknown
- acceptsunknown
Any
- accepts'any
Json
- acceptsJs.Json.t
JsonString
- acceptsstring
and appliesJSON.parse
before parsing
You can configure compiled function output
with the following options:
Value
- returns'value
ofS.t<'value>
Unknown
- returnsunknown
Assert
- returnsunit
Json
- validates that the schema is JSON compatible and returnsJs.Json.t
JsonString
- validates that the schema is JSON compatible and converts output to JSON string
You can configure compiled function mode
with the following options:
Sync
- for sync operationsAsync
- for async operations - will wrap result in a promise
And you can configure compiled function typeValidation
with the following options:
true (default)
- performs type validationfalse
- doesn't perform type validation and only converts data to the output format. Note that refines are still applied.
(S.t<'value>) => S.t<'value>
S.null(S.string)->S.reverse
// S.option(S.string)
let schema = S.object(s => s.field("foo", S.string))
{"foo": "bar"}->S.parseOrThrow(schema)
// "bar"
let reversed = schema->S.reverse
"bar"->S.parseOrThrow(reversed)
// {"foo": "bar"}
123->S.parseOrThrow(reversed)
// throws S.error with the message: `Failed parsing at root. Reason: Expected string, received 123`
Reverses the schema. This gets especially magical for schemas with transformations 🪄
(S.t<'value>) => S.tagged
S.string->S.classify
// String
This can be useful for building other tools like rescript-json-schema
.
(S.t<'value>) => bool
S.string->S.isAsync
// false
S.string->S.transform(_ => {asyncParser: i => Promise.resolve(i)})->S.isAsync
// true
Determines if the schema is async. It can be useful to decide whether you should use async operation.
(S.t<'value>) => string
S.literal({"abc": 123})->S.name
// `{ "abc": 123 }`
Used internally for readable error messages.
🧠 Names are subject to change in the future versions
(S.t<'value>, string) => string
let schema = S.literal({"abc": 123})->S.setName("Abc")
schema->S.name
// `Abc`
You can customise a schema name using S.setName
.
S.t<'value> => S.t<'value>
let schema = S.object(s => s.field("abc", S.int))->S.removeTypeValidation
{
"abc": 123,
}->S.parseOrThrow(schema) // This doesn't have `if (!i || i.constructor !== Object) {` check. But field types are still validated.
// 123
Removes type validation for provided schema. Nested schemas are not affected.
This can be useful to optimise S.object
parsing when you construct the input data yourself.
rescript-schema throws S.error
error containing detailed information about the validation problems.
let schema = S.literal(false)
true->S.parseOrThrow(schema)
// throws S.error with the message: `Failed parsing at root. Reason: Expected false, received true`
If you want to handle the error, the best way to use try/catch
block:
try true->S.parseOrThrow(schema) catch {
| S.Error.Raised(error) => Console.log(error->S.Error.message)
}
(~code: S.errorCode, ~flag: S.flag, ~path: S.Path.t) => S.error
Creates an instance of RescriptSchemaError
error. At the same time it's the S.Raised
exception.
S.error => exn
Throws error. Since internally it's both the S.Raised
exception and instance of RescriptSchemaError
, it'll have a nice error message and can be caught using S.Raised
.
S.error => string
{
code: InvalidType({expected: S.literal(false), received: true}),
flag: S.Flag.typeValidation,
path: S.Path.empty,
}->S.Error.message
"Failed parsing at root. Reason: Expected false, received true"
S.error => string
{
code: InvalidType({expected: S.literal(false), received: true}),
flag: S.Flag.typeValidation,
path: S.Path.empty,
}->S.Error.reason
"Expected false, received true"
rescript-schema has a global config that can be changed to customize the behavior of the library.
defaultUnknownKeys
is an option that controls how unknown keys are handled when parsing objects. The default value is Strip
, but you can globally change it to Strict
to enforce strict object parsing.
S.setGlobalConfig({
defaultUnknownKeys: Strict,
})
disableNanNumberValidation
is an option that controls whether the library should check for NaN values when parsing numbers. The default value is false
, but you can globally change it to true
to allow NaN values. If you parse many numbers which are guaranteed to be non-NaN, you can set it to true
to improve performance ~10%, depending on the case.
S.setGlobalConfig({
disableNanNumberValidation: true,
})