- Table of contents
- Install
- Basic usage
- Primitives
- Literals
- Strings
- Numbers
- NaNs
- Optionals
- Nullables
- Nullish
- Objects
- Arrays
- Tuples
- Unions
- Records
- JSON
- JSON string
- Describe
- Custom schema
- Recursive schemas
- Refinements
- Transforms
- Functions on schema
- Error handling
- Comparison
- Global config
npm install rescript-schema rescript@11
🧠 Even though
rescript
is a peer dependency, you don't need to use the compiler. It's only needed for a few lightweight runtime helpers.
import * as S from "rescript-schema";
// Create login schema with email and password
const loginSchema = S.schema({
email: S.email(S.string),
password: S.stringMinLength(S.string, 8),
});
// Infer output TypeScript type of login schema
type LoginData = S.Output<typeof loginSchema>; // { email: string; password: string }
// Throws the S.Error(`Failed parsing at ["email"]. Reason: Invalid email address`)
S.parseOrThrow({ email: "", password: "" }, loginSchema);
// Returns data as { email: string; password: string }
S.parseOrThrow(
{
email: "jane@example.com",
password: "12345678",
},
loginSchema
);
import * as S from "rescript-schema";
// primitive values
S.string;
S.number;
S.int32;
S.boolean;
S.bigint;
// empty type
S.undefined;
// catch-all types
// allows any value
S.unknown;
// never type
// allows no values
S.never;
Literal schemas represent a literal type, like "hello world"
or 5
.
const tuna = S.schema("tuna");
const twelve = S.schema(12);
const twobig = S.schema(2n);
const tru = S.schema(true);
const terrificSymbol = Symbol("terrific");
const terrific = S.schema(terrificSymbol);
Compared to other libraries, S.schema
in rescript-schema supports literally any value. They are validated using strict equal checks. With the exception of plain objects and arrays, they are validated using deep equal checks. So the schema like this will work correctly:
const cliArgsSchema = S.schema(["help", "lint"]);
// ^ This is going to be a S.Schema<["help", "lint"]> type
rescript-schema includes a handful of string-specific refinements and transforms:
S.stringMaxLength(S.string, 5); // String must be 5 or fewer characters long
S.stringMinLength(S.string, 5); // String must be 5 or more characters long
S.stringLength(S.string, 5); // String must be exactly 5 characters long
S.email(S.string); // Invalid email address
S.url(S.string); // Invalid url
S.uuid(S.string); // Invalid UUID
S.cuid(S.string); // Invalid CUID
S.pattern(S.string, %re(`/[0-9]/`)); // Invalid
S.datetime(S.string); // Invalid datetime string! Must be UTC
S.trim(S.string); // 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.stringMinLength(S.string, 1, "String can't be empty");
S.stringLength(S.string, 5, "SMS code should be 5 digits long");
The S.datetime(S.string)
function has following UTC validation: no timezone offsets with arbitrary sub-second decimal precision.
const datetimeSchema = S.datetime(S.string);
// The datetimeSchema has the type S.Schema<Date, string>
// String is transformed to the Date instance
S.parseOrThrow("2020-01-01T00:00:00Z", datetimeSchema); // pass
S.parseOrThrow("2020-01-01T00:00:00.123Z", datetimeSchema); // pass
S.parseOrThrow("2020-01-01T00:00:00.123456Z", datetimeSchema); // pass (arbitrary precision)
S.parseOrThrow("2020-01-01T00:00:00+02:00", datetimeSchema); // fail (no offsets allowed)
rescript-schema includes some of number-specific refinements:
S.numberMax(S.number, 5); // Number must be lower than or equal to 5
S.numberMin(S.number 5); // Number must be greater than or equal to 5
Optionally, you can pass in a second argument to provide a custom error message.
S.numberMax(S.number, 5, "this👏is👏too👏big");
There's no specific schema for NaN, just use S.schema
as for everything else:
const nanSchema = S.schema(NaN);
It's going to use Number.isNaN
check under the hood.
You can make any schema optional with S.optional
.
const schema = S.optional(S.string);
S.parseOrThrow(undefined, schema); // => returns undefined
type A = S.Output<typeof schema>; // string | undefined
You can pass a default value to the second argument of S.optional
.
const stringWithDefaultSchema = S.optional(S.string, "tuna");
S.parseOrThrow(undefined, stringWithDefaultSchema); // => returns "tuna"
type A = S.Output<typeof stringWithDefaultSchema>; // string
Optionally, you can pass a function as a default value that will be re-executed whenever a default value needs to be generated:
const numberWithRandomDefault = S.optional(S.number, Math.random);
S.parseOrThrow(undefined, numberWithRandomDefault); // => 0.4413456736055323
S.parseOrThrow(undefined, numberWithRandomDefault); // => 0.1871840107401901
S.parseOrThrow(undefined, numberWithRandomDefault); // => 0.7223408162401552
Conceptually, this is how rescript-schema processes default values:
- If the input is
undefined
, the default value is returned - Otherwise, the data is parsed using the base schema
Similarly, you can create nullable types with S.nullable
.
const nullableStringSchema = S.nullable(S.string);
S.parseOrThrow("asdf", nullableStringSchema); // => "asdf"
S.parseOrThrow(null, nullableStringSchema); // => undefined
Notice how the null
input transformed to undefined
.
A convenience method that returns a "nullish" version of a schema. Nullish schemas will accept both undefined
and null
. Read more about the concept of "nullish" in the TypeScript 3.7 release notes.
const nullishStringSchema = S.nullish(S.string);
S.parseOrThrow("asdf", nullishStringSchema); // => "asdf"
S.parseOrThrow(null, nullishStringSchema); // => undefined
S.parseOrThrow(undefined, nullishStringSchema); // => undefined
// all properties are required by default
const dogSchema = S.schema({
name: S.string,
age: S.number,
});
// extract the inferred type like this
type Dog = S.Output<typeof dogSchema>;
// equivalent to:
type Dog = {
name: string;
age: number;
};
Besides passing schemas for values in S.schema
, you can also pass any Js value and it'll be treated as a literal field.
const meSchema = S.schema({
id: S.number,
name: "Dmitry Zakharov",
age: 23
kind: "human",
metadata: {
description: "What?? Even an object with NaN works! Yes 🔥",
money: NaN,
} ,
});
You can add as const
or wrap the value with S.schema
to adjust the schema type. The example below turns the kind
field to be a "human"
type instead of string
:
S.schema({
kind: "human" as const,
// Or
kind: S.schema("human"),
});
This is useful for discriminated unions.
Sometimes you want to transform the data coming to your system. You can easily do it by passing a function to the S.object
schema.
const userSchema = S.object((s) => ({
id: s.field("USER_ID", S.number),
name: s.field("USER_NAME", S.string),
}));
S.parseOrThrow(
{
USER_ID: 1,
USER_NAME: "John",
},
userSchema
);
// => returns { id: 1, name: "John" }
// Infer output TypeScript type of the userSchema
type User = S.Output<typeof userSchema>; // { id: number; name: string }
Compared to using S.transform
, the approach has 0 performance overhead. Also, you can use the same schema to convert the parsed data back to the initial format:
S.reverseConvertOrThrow(
{
id: 1,
name: "John",
},
userSchema
);
// => returns { USER_ID: 1, USER_NAME: "John" }
By default rescript-schema object schema strip out unrecognized keys during parsing. You can disallow unknown keys with S.strict
function. If there are any unknown keys in the input, rescript-schema will fail with an error.
const personSchema = S.strict(
S.schema({
name: S.string,
})
);
S.parseOrThrow(
{
name: "bob dylan",
extraKey: 61,
},
personSchema
);
// => throws S.Error
If you want to change it for all schemas in your app, you can use S.setGlobalConfig
function:
S.setGlobalConfig({
defaultUnknownKeys: "Strict",
});
Use the S.strip
function to reset an 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({
bar: {
baz: S.string,
},
});
S.strict(schema); // { "baz": string } will still allow unknown keys
S.deepStrict(schema); // { "baz": string } will not allow unknown keys
You can add additional fields to an object schema with the merge
function.
const baseTeacherSchema = S.schema({ students: S.array(S.string) });
const hasIDSchema = S.schema({ id: S.string });
const teacherSchema = S.merge(baseTeacherSchema, hasIDSchema);
type Teacher = S.Output<typeof teacherSchema>; // => { students: string[], id: string }
🧠 The function will throw if the schemas share keys. The returned schema also inherits the "unknownKeys" policy (strip/strict) of B.
const stringArraySchema = S.array(S.string);
rescript-schema includes some of array-specific refinements:
S.arrayMaxLength(S.array(S.string), 5); // Array must be 5 or fewer items long
S.arrayMinLength(S.array(S.string) 5); // Array must be 5 or more items long
S.arrayLength(S.array(S.string) 5); // Array must be exactly 5 items long
Unlike arrays, tuples have a fixed number of elements and each element can have a different type.
const athleteSchema = S.schema([
S.string, // name
S.number, // jersey number
{
pointsScored: S.number,
}, // statistics
]);
type Athlete = S.Output<typeof athleteSchema>;
// type Athlete = [string, number, { pointsScored: number }]
Sometimes you want to transform incoming tuples to a more convenient data-structure. To do this you can pass a function to the S.tuple
schema.
const athleteSchema = S.tuple((s) => ({
name: s.item(0, S.string),
jerseyNumber: s.item(1, S.number),
statistics: s.item(
2,
S.schema({
pointsScored: S.number,
})
),
}));
type Athlete = S.Output<typeof athleteSchema>;
// type Athlete = {
// name: string;
// jerseyNumber: number;
// statistics: {
// pointsScored: number;
// };
// }
That looks much better than before. And the same as for advanced objects, you can use the same schema for transforming the parsed data back to the initial format. Also, it has 0 performance overhead and is as fast as parsing tuples without the transformation.
An union represents a logical OR relationship. You can apply this concept to your schemas with S.union
. The same api works for discriminated unions as well.
The schema function union
creates an OR relationship between any number of schemas that you pass as the first argument in the form of an array. On validation, the schema returns the result of the first schema 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 Union = string | number;
const stringOrNumberSchema = S.union([S.string, S.number]);
S.parseOrThrow("foo", stringOrNumberSchema); // passes
S.parseOrThrow(14, stringOrNumberSchema); // passes
// TypeScript type for reference:
// type Shape =
// | { kind: "circle"; radius: number }
// | { kind: "square"; x: number }
// | { kind: "triangle"; x: number; y: number };
const shapeSchema = S.union([
{
kind: "circle" as const,
radius: S.number,
},
{
kind: "square" as const,
x: S.number,
},
{
kind: "triangle" as const,
x: S.number,
y: S.number,
},
]);
Creating a schema for a enum-like union was never so easy:
const schema = S.union(["Win", "Draw", "Loss"]);
typeof S.Output<schema>; // Win | Draw | Loss
Record schema is used to validate types such as { [k: string]: number }
.
If you want to validate the values of an object against some schema but don't care about the keys, use S.record(valueSchema)
:
const numberCacheSchema = S.record(S.number);
type NumberCache = S.Output<typeof numberCacheSchema>;
// => { [k: string]: number }
The S.json
schema makes sure that the value is compatible with JSON.
It accepts a boolean as an argument. If it's true, then the value will be validated as valid JSON; otherwise, it unsafely casts it to the S.Json
type.
S.parseOrThrow(`"foo"`, S.json(true)); // passes
const schema = S.jsonString(S.int);
S.parseOrThrow("123", schema);
// => 123
The S.jsonString
schema represents JSON string containing value of a specific type.
Use S.describe
to add a description
property to the resulting schema.
const documentedStringSchema = S.describe(
S.string,
"A useful bit of text, if you know what to do with it."
);
S.description(documentedStringSchema); // 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
.
You can create a schema for any TypeScript type by using S.custom
. This is useful for creating schema for types that are not supported by rescript-schema out of the box.
const mySetSchema = S.custom("MySet", (input, s) => {
if (input instanceof Set) {
return input;
}
throw s.fail("Provided data is not an instance of Set.");
});
type MySet = S.Output<typeof mySetSchema>; // Set<any>
You can define a recursive schema in rescript-schema. Unfortunately, TypeScript derives the Schema type as unknown
so you need to explicitly specify the type and it'll start correctly typechecking.
type Node = {
id: string;
children: Node[];
};
let nodeSchema = S.recursive<Node>((nodeSchema) =>
S.schema({
id: S.string,
children: S.array(nodeSchema),
})
);
🧠 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.
const shortStringSchema = S.refine(S.string, (value, s) => {
if (value.length > 255) {
s.fail("String can't be more than 255 characters");
}
});
The refine function is applied for both parser and serializer.
Also, you can have an asynchronous refinement (for parser only):
const userSchema = S.schema({
id: S.asyncParserRefine(S.uuid(S.string), async (id, s) => {
const isActiveUser = await checkIsActiveUser(id);
if (!isActiveUser) {
s.fail(`The user ${id} is inactive.`);
}
}),
name: S.string,
});
type User = S.Output<typeof userSchema>; // { id: string, name: string }
// Need to use parseAsync which will return a promise with S.Result
await S.parseAsyncOrThrow(
{
id: "1",
name: "John",
},
userSchema
);
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.
const intToString = (schema) =>
S.transform(
schema,
(int) => int.toString(),
(string, s) => {
const int = parseInt(string, 10);
if (isNaN(int)) {
s.fail("Can't convert string to int");
}
return int;
}
);
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 | (unknown, Schema<Output, Input>) => Output |
Parses any value with the schema |
S.parseJsonOrThrow | (Json, Schema<Output, Input>) => Output |
Parses JSON value with the schema |
S.parseJsonStringOrThrow | (string, Schema<Output, Input>) => Output |
Parses JSON string with the schema |
S.parseAsyncOrThrow | (unknown, Schema<Output, Input>) => Promise<Output> |
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 | (Input, Schema<Output, Input>) => Output |
Converts input value to the output type |
S.convertToJsonOrThrow | (Input, Schema<Output, Input>) => Json |
Converts input value to JSON |
S.convertToJsonStringOrThrow | (Input, Schema<Output, Input>) => string |
Converts input value to JSON string |
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 | (Output, Schema<Output, Input>) => Input |
Converts schema value to the output type |
S.reverseConvertToJsonOrThrow | (Output, Schema<Output, Input>) => Json |
Converts schema value to JSON |
S.reverseConvertToJsonStringOrThrow | (Output, Schema<Output, Input>) => string |
Converts schema value to JSON string |
S.reverseConvertAsyncOrThrow | (Output, Schema<Output, Input>) => promise<Input> |
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 | (data: unknown, Schema<Output, Input>) asserts data is Input |
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 throw an error. For convinient error handling you can use the S.safe
and S.safeAsync
helpers, which would catch the error an wrap it into a Result
type:
const result = S.safe(() => S.parseOrThrow(123, S.string));
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.
const operation = S.compile(S.string, "Any", "Assert", "Async");
typeof operation; // => (input: unknown) => Promise<void>
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:
Output
- acceptsOutput
ofSchema<Output, Input>
and reverses the operationInput
- acceptsInput
ofSchema<Output, Input>
which only affects the operation function argument typeAny
- acceptsunknown
Json
- acceptsJson
JsonString
- acceptsstring
and appliesJSON.parse
before parsing
You can configure compiled function output
with the following options:
Output
- returnsOutput
ofSchema<Output, Input>
Input
- returnsInput
ofSchema<Output, Input>
Assert
- returnsvoid
withasserts data is T
guardJson
- 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 return value 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.reverse(S.nullable(S.string));
// S.optional(S.string)
const schema = S.object((s) => s.field("foo", S.string));
S.parseOrThrow({ foo: "bar" }, schema);
// "bar"
const reversed = S.reverse(schema);
S.parseOrThrow("bar", reversed);
// {"foo": "bar"}
S.parseOrThrow(123, 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.name(S.schema({ abc: 123 }));
// `{ abc: 123; }`
Used internally for readable error messages.
🧠 Subject to change
const schema = S.setName(S.schema({ abc: 123 }, "Abc"));
S.name(schema);
// `Abc`
You can customise a schema name using S.setName
.
rescript-schema throws S.Error
which is a subclass of Error class. It contains detailed information about the operation problem.
S.parseOrThrow(true, S.schema(false));
// => Throws S.Error with the following message: Failed parsing at root. Reason: Expected false, received true".
You can catch the error using S.safe
and S.safeAsync
helpers:
const result = S.safe(() => S.parseOrThrow(true, S.schema(false)));
if (result.success) {
console.log(result.value);
} else {
console.log(result.error);
}
Or the async version:
const result = await S.safeAsync(async () => {
let passed = await S.parseAsyncOrThrow(data, S.schema(S.boolean));
return passed ? 1 : 0;
});
As you can notice, you can have more logic inside of the safe function callback and still be sure that the error will be caught in a functional way.
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,
})