JavaScript API reference
August 2, 2026 · View on GitHub
JavaScript API reference
Table of contents
- Table of contents
- Install
- Basic usage
- Defining schemas
- Strings
- Numbers
- Optionals
- Nullables
- Nullish
- Objects
- Arrays
- Tuples
- Unions
- Records
- Date
- ISO DateTime
- Instance
- Meta
- Brand
- Custom schema
- Recursive schemas
- Refinements
- Functions on schema
- Error handling
- Global config
Install
npm install sury
🧠 You don't need to install ReScript compiler for the library to work.
Basic usage
The main building block of Sury is a schema — a type definition that exists at runtime.
import * as S from "sury"; // 9.77 kB (min + gzip)
const playerSchema = S.schema({
username: S.string,
xp: S.number,
});
Parsing data
Parses unknown data and returns a strongly-typed deep clone of the input, with unknown fields stripped by default:
S.parser(playerSchema)({ username: "billie", xp: 100 });
// => returns { username: "billie", xp: 100 }
Invalid data throws S.Error:
S.parser(playerSchema)({ username: "billie", xp: "not a number" });
// => throws S.Error: Failed at ["xp"]: Expected number, received "not a number"
Use S.safe / S.safeAsync if you'd rather have a result than an exception — see Error handling.
🧠 Besides
parserthere are operations to transform without validation, assert without allocating an output, and serialize back to the input format. See Functions on schema.
Inferred types
Sury infers the static type from the schema definition. Extract it with S.Infer<typeof schema>, S.Output<typeof schema>, or S.Input<typeof schema>:
const playerSchema = S.schema({
username: S.string,
xp: S.number,
});
//? S.Schema<{ username: string; xp: number }, { username: string; xp: number }>
type Player = S.Infer<typeof playerSchema>;
The type parameters read in the direction data flows: S.Schema<TInput, TOutput> — the encoded type the schema accepts, then the decoded type it produces. TOutput defaults to TInput, so an identity schema is just S.Schema<string>.
To annotate "any schema producing T, whatever it accepts", leave the input as unknown:
const parseT = <T>(schema: S.Schema<unknown, T>, data: unknown): T =>
S.parser(schema)(data);
Serializing data
Every schema has an Input type as well as an Output type, so the same definition serializes back to the input format:
S.encoder(playerSchema)({ username: "billie", xp: 100 });
// => returns { username: "billie", xp: 100 }
That's uneventful without transformations. Add some — to for coercion, shape for restructuring — and the reverse direction comes with them:
const userSchema = S.schema({
USER_ID: S.string.with(S.to, S.bigint),
USER_NAME: S.string,
}).with(S.shape, (input) => ({
id: input.USER_ID,
name: input.USER_NAME,
}));
//? S.Schema<{ USER_ID: string; USER_NAME: string }, { id: bigint; name: string }>
S.parser(userSchema)({ USER_ID: "0", USER_NAME: "Dmitry" });
// { id: 0n, name: "Dmitry" }
S.encoder(userSchema)({ id: 0n, name: "Dmitry" });
// { USER_ID: "0", USER_NAME: "Dmitry" }
S.encoder skips validation. For a validating reverse pass, use reverse, which returns a full-featured schema with Input and Output swapped:
S.parser(S.reverse(userSchema))({ id: 0n, name: "Dmitry" });
// { USER_ID: "0", USER_NAME: "Dmitry" }
JSON Schema
S.toJSONSchema(schema, { target }) emits "draft-07" (default), "draft-2020-12", or "openapi-3.0". Properties and examples come out in the Input format:
const documented = userSchema.with(S.meta, {
description: "User entity in our system",
examples: [{ id: 0n, name: "Dmitry" }],
});
S.toJSONSchema(documented);
// {
// type: "object",
// properties: {
// USER_ID: { type: "string" },
// USER_NAME: { type: "string" },
// },
// required: ["USER_ID", "USER_NAME"],
// description: "User entity in our system",
// examples: [{ USER_ID: "0", USER_NAME: "Dmitry" }],
// }
S.fromJSONSchema converts in the other direction:
S.assert(
S.fromJSONSchema({
type: "string",
format: "email",
}),
"example.com"
);
// Throws S.Error: Expected email, received "example.com"
🧠 Sury's internal representation is itself JSON Schema-shaped, so a schema is readable as-is:
S.schema("Hello world!")logs{ type: "string", const: "Hello world!", … }.
Standard Schema
Sury implements the Standard Schema specification:
schema["~standard"].validate({ name: "Dmitry" });
// { value: { name: "Dmitry" } }
schema["~standard"].validate({ name: 1 });
// { issues: [{ message: "Expected string, received 1", path: ["name"] }] }
The ~standard property also implements the Standard JSON Schema spec, exposing a jsonSchema converter for the schema's input and output types. Call S.enableStandardJSONSchema() once to enable it:
S.enableStandardJSONSchema();
const schema = S.string.with(S.to, S.number);
schema["~standard"].jsonSchema.input({ target: "draft-2020-12" });
// { $schema: "https://json-schema.org/draft/2020-12/schema", type: "string" }
schema["~standard"].jsonSchema.output({ target: "draft-2020-12" });
// { $schema: "https://json-schema.org/draft/2020-12/schema", type: "number" }
🧠
jsonSchema.input(options)equalsS.toJSONSchema(schema, options)and.output(options)equalsS.toJSONSchema(S.reverse(schema), options), so thetargetoption behaves the same as above. Theoptionsargument is required by the spec.
Defining schemas
import * as S from "sury";
// Primitive values
S.string;
S.number;
S.int32;
S.boolean;
S.bigint;
S.symbol;
S.void;
// Literal values
// Supports any JS type
// Validated using strict equal checks
S.schema("tuna");
S.schema(12);
S.schema(2n);
S.schema(true);
S.schema(undefined);
S.schema(null);
S.schema(Symbol("terrific"));
S.literal("tuna"); // alias for S.schema
// NaN literals
// Validated using Number.isNaN
S.schema(NaN);
// Simple Objects
S.schema({ name: S.string, age: S.number });
S.object({ name: S.string, age: S.number }); // alias for S.schema
// Arrays and records
S.array(S.string);
S.record(S.number); // { [k: string]: number }
// Simple Tuples
S.schema([S.string, S.number]);
S.tuple([S.string, S.number]); // alias for S.schema
// Unions
S.union([S.string, S.number]);
// Enum-like union of literals
S.union(["Win", "Draw", "Loss"]);
// Discriminated unions
S.union([
{ kind: "circle", radius: S.number },
{ kind: "square", x: S.number },
]);
// Catch-all type
// Allows any value
S.unknown;
S.any; // alias for S.unknown, typed as S.Schema<any, any>
// Never type
// Allows no values
S.never;
🧠
S.schematurns any definition into a schema —S.literal,S.objectandS.tupleare aliases for it. OnlyS.objectandS.tuplealso take a definer function, for advanced object and advanced tuple schemas.
Advanced schemas
🧠 Don't forget
S.towhich comes with powerful coercion logic.
// JSON type
// Allows string | boolean | number | null | Record<string, JSON> | JSON[]
S.json;
// JSON string
// Asserts that the input is a valid JSON string
S.jsonString;
S.jsonStringWithSpace(2);
// Parses JSON string and validates that it's a number
// JSON string -> number
S.jsonString.with(S.to, S.number);
// Serializes number to JSON string
S.number.with(S.to, S.jsonString);
// Asserts that the input is a Date instance and not Invalid Date
S.date;
// Asserts that the input is an instance of Uint8Array
S.uint8Array;
// Decodes Uint8Array to utf-8 string
S.uint8Array.with(S.to, S.string);
// Encodes utf-8 string to Uint8Array
S.string.with(S.to, S.uint8Array);
Strings
Sury includes a handful of string-specific refinements and transforms:
S.max(S.string, 5); // String must be 5 or fewer characters long
S.min(S.string, 5); // String must be 5 or more characters long
S.length(S.string, 5); // String must be exactly 5 characters long
S.string.with(S.pattern, /[0-9]/); // Invalid pattern
S.trim(S.string); // trim whitespaces
For format-specific string validation, use the standalone schemas:
S.email; // Standalone email schema
S.url; // Standalone URL schema
S.uuid; // Standalone UUID schema
S.cuid; // Standalone CUID schema
For ISO 8601 UTC datetime strings use the dedicated standalone
S.isoDateTimeschema — see ISO datetimes below.
⚠️ 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, Sury picks a relatively simple regex that does not cover all cases.
When using built-in refinements, you can provide a custom error message.
S.min(S.string, 1, "String can't be empty");
S.length(S.string, 5, "SMS code should be 5 digits long");
Custom error messages
Built-in refinements accept an optional last argument for a custom error message:
S.min(S.string, 5, "Too short");
S.pattern(S.string, /^\d+$/, "Must be numeric");
For standalone schemas or more control, use S.meta with the errorMessage field:
// Override a specific constraint message
S.email.with(S.meta, { errorMessage: { format: "Must be a valid email" } });
// Use "_" as a catch-all for any constraint
S.email.with(S.meta, { errorMessage: { _: "Invalid input" } });
// Reset error messages (removes all overrides)
schema.with(S.meta, { errorMessage: {} });
Available keys: format, type, minimum, maximum, minLength, maxLength, minItems, maxItems, pattern, _ (catch-all).
ISO datetimes
S.isoDateTime is a standalone string schema (S.Schema<string, string>) that validates ISO 8601 UTC datetime strings: no timezone offsets allowed, with arbitrary sub-second decimal precision.
const schema = S.isoDateTime;
// schema has the type S.Schema<string, string>
S.parser(schema)("2020-01-01T00:00:00Z"); // pass
S.parser(schema)("2020-01-01T00:00:00.123Z"); // pass
S.parser(schema)("2020-01-01T00:00:00.123456Z"); // pass (arbitrary precision)
S.parser(schema)("2020-01-01T00:00:00+02:00"); // fail (no offsets allowed)
To decode an ISO datetime string into a Date, chain it with .with(S.to, S.date):
const schema = S.string.with(S.to, S.date);
// schema has the type S.Schema<string, Date>
Numbers
Sury includes some of number-specific refinements:
S.max(S.number, 5); // Number must be lower than or equal to 5
S.min(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.max(S.number, 5, "this👏is👏too👏big");
Optionals
You can make any schema optional with S.optional.
const schema = S.optional(S.string);
S.parser(schema)(undefined); // => returns undefined
type A = S.Infer<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.parser(stringWithDefaultSchema)(undefined); // => returns "tuna"
type A = S.Infer<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.parser(numberWithRandomDefault)(undefined); // => 0.4413456736055323
S.parser(numberWithRandomDefault)(undefined); // => 0.1871840107401901
S.parser(numberWithRandomDefault)(undefined); // => 0.7223408162401552
Conceptually, this is how Sury processes default values:
- If the input is
undefined, the default value is returned - Otherwise, the data is parsed using the base schema
Nullables
Similarly, you can create nullable types with S.nullable.
const nullableStringSchema = S.nullable(S.string);
S.parser(nullableStringSchema)("asdf"); // => "asdf"
S.parser(nullableStringSchema)(null); // => null
Pass a fallback as the second argument to replace the absent case:
S.parser(S.nullable(S.string, "fallback"))(null); // => "fallback"
Nullish
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.parser(nullishStringSchema)("asdf"); // => "asdf"
S.parser(nullishStringSchema)(null); // => null
S.parser(nullishStringSchema)(undefined); // => undefined
Objects
// 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.Infer<typeof dogSchema>;
// equivalent to:
type Dog = {
name: string;
age: number;
};
Literal fields
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,
} ,
});
Literal fields keep their narrow type — kind above is "human", not string — which is what makes discriminated unions work.
Advanced object schema
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.parser(userSchema)({
USER_ID: 1,
USER_NAME: "John",
});
// => returns { id: 1, name: "John" }
// Infer output TypeScript type of the userSchema
type User = S.Infer<typeof userSchema>; // { id: number; name: string }
Compared to using custom transformation functions, the approach has 0 performance overhead. Also, you can use the same schema to convert the parsed data back to the initial format:
S.encoder(userSchema)({
id: 1,
name: "John",
});
// => returns { USER_ID: 1, USER_NAME: "John" }
strict
By default Sury 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, Sury will fail with an error.
const personSchema = S.strict(
S.schema({
name: S.string,
})
);
S.parser(personSchema)({
name: "bob dylan",
extraKey: 61,
});
// => throws S.Error
If you want to change it for all schemas in your app, you can use S.global function:
S.global({
defaultAdditionalItems: "strict",
});
strip
Use the S.strip function to reset an object schema to the default behavior (stripping unrecognized keys).
deepStrict & deepStrip
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.
const 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
merge
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.Infer<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.
Arrays
const stringArraySchema = S.array(S.string);
Sury includes some of array-specific refinements:
S.max(S.array(S.string), 5); // Array must be 5 or fewer items long
S.min(S.array(S.string), 5); // Array must be 5 or more items long
S.length(S.array(S.string), 5); // Array must be exactly 5 items long
Compact Columns
S.compactColumns flattens an array of objects into one array of values per field, and back again:
const rowSchema = S.schema({
id: S.string,
name: S.string,
deleted: S.boolean,
});
const schema = S.compactColumns(S.json).with(S.to, S.array(rowSchema));
S.encoder(schema)([
{ id: "0", name: "Hello", deleted: false },
{ id: "1", name: "World", deleted: true },
]);
// [["0", "1"], ["Hello", "World"], [false, true]]
S.parser(schema)([["0", "1"], ["Hello", "World"], [false, true]]);
// [{ id: "0", name: "Hello", deleted: false }, { id: "1", name: "World", deleted: true }]
The layout is the one described in Boosting Postgres INSERT Performance by 2x With UNNEST.
Checkout the compiled code yourself:
(i) => {
let v4 = [new Array(i.length), new Array(i.length), new Array(i.length)];
for (let v3 = 0; v3 < i.length; ++v3) {
v4[0][v3] = i[v3]["id"];
v4[1][v3] = i[v3]["name"];
v4[2][v3] = i[v3]["deleted"];
}
return v4;
};
Tuples
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.Infer<typeof athleteSchema>;
// type Athlete = [string, number, { pointsScored: number }]
Advanced tuple schema
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.Infer<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.
Unions
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.
🧠 Members are matched in the order they are passed to
S.union— the first one that fits the value wins.
// TypeScript type for reference:
// type Union = string | number;
const stringOrNumberSchema = S.union([S.string, S.number]);
S.parser(stringOrNumberSchema)("foo"); // passes
S.parser(stringOrNumberSchema)(14); // passes
Discriminated unions
// 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",
radius: S.number,
},
{
kind: "square",
x: S.number,
},
{
kind: "triangle",
x: S.number,
y: S.number,
},
]);
Converting to / from a union
S.to works with unions on either side of the conversion. There are
three cases.
Single type → union. Members are tried in the order you wrote them; the first one that accepts the value wins:
const schema = S.json.with(S.to, S.union([S.bigint, S.string]));
S.parser(schema)("123"); // 123n — the bigint member comes first
S.parser(schema)("abc"); // "abc" — not a valid bigint, so the string member takes it
S.parser(schema)(true); // throws — no member accepts a boolean
Notice that true wasn't converted to "true", even though boolean → string
is a supported conversion. A value is only converted into a member type the
source can't produce itself: JSON has no bigints, so strings are offered to
S.bigint — but JSON already has strings, so the S.string member only
accepts actual strings.
Union → single type. The mirror image — each member converts to the target
the same way it would with a direct S.to:
const schema = S.union([S.bigint, S.boolean]).with(S.to, S.string);
S.parser(schema)(123n); // "123"
S.parser(schema)(true); // "true"
Union → union. Values pass through to the member of the same type on the
other side — nothing is converted, so every member needs a counterpart. The one
exception: an undefined member without a counterpart may pair with a null
member on the other side, and vice versa:
S.union([S.string, S.number]).with(S.to, S.union([S.number, S.string])); // ✅ both pass through
S.optional(S.string).with(S.to, S.nullable(S.string)); // ✅ undefined <-> null
S.optional(S.string).with(S.to, S.nullable(S.boolean)); // ❌ string has no counterpart
Good to know:
- Formats count as distinct types:
S.int32won't match a plainS.numbermember, andS.jsonwon't matchS.string. - Nested unions are treated as one flat union:
S.union([S.string, S.union([S.number, S.boolean])])has three members. - When a value fails a member — wrong type, failed refinement, or an error thrown inside it — the next member gets a try. Only when all members fail does the union throw, listing each member's reason.
When a conversion is rejected
Some conversions have more than one reasonable meaning, and some have none.
Rather than guess, Sury rejects those with an Invalid operation error right
at the S.parser / S.encoder call — not later, on each value — and the
error suggests a rewrite that says what you mean.
Ambiguous. Given "123" — should it stay a string, or become a number?
Both readings are sensible, so Sury makes you pick:
S.string.with(S.to, S.union([S.number, S.string]));
// Invalid operation: can't convert string to number | string — string has the same
// type as the source and the others don't.
// Convert to a number when possible, keep the string otherwise:
const asNumber = S.string.with(S.to, S.union([S.string.with(S.to, S.number), S.string]));
S.parser(asNumber)("123"); // 123
S.parser(asNumber)("abc"); // "abc"
// Or pass strings through, never producing a number:
const asString = S.string.with(S.to, S.union([S.never.with(S.to, S.number), S.string]));
S.parser(asString)("123"); // "123"
S.parser(asString)("abc"); // "abc"
The two unions don't cover each other. Union-to-union converts nothing, so a member with no same-type counterpart has nowhere to go:
S.union([S.string, S.number]).with(S.to, S.union([S.number, S.string, S.boolean]));
// Invalid operation: … boolean has no same-type variant on the other side.
S.optional(S.string).with(S.to, S.nullable(S.boolean)); // ❌ string doesn't match boolean
S.optional(S.string).with(S.to, S.nullable(S.string.with(S.to, S.boolean))); // ✅
No conversion exists. If a conversion between two types isn't supported
outside a union, putting it inside one doesn't change that. Use S.never to
mark a member as unreachable:
S.boolean.with(S.to, S.union([S.string, S.symbol])); // ❌ boolean -> symbol isn't supported
S.union([S.boolean, S.symbol]).with(S.to, S.string); // ❌ symbol -> string isn't supported
S.boolean.with(S.to, S.union([S.string, S.never.with(S.to, S.symbol)])); // ✅ symbol marked unreachable
🧠 Union conversion always validates every member, so transformed unions stay consistent across decode and encode.
Records
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.Infer<typeof numberCacheSchema>;
// => { [k: string]: number }
Date
S.date validates that the input is a Date instance and rejects Invalid Date.
S.parser(S.date)(new Date()); // passes
S.parser(S.date)(new Date("2024-01-01T00:00:00Z")); // passes
S.parser(S.date)(new Date("invalid")); // throws
S.parser(S.date)("2024-01-01"); // throws - not a Date instance
Unlike
S.isoDateTime(which validates ISO datetime strings) andS.string.with(S.to, S.date)(which decodes ISO strings into Date objects),S.datevalidates existing Date instances directly.
You can use S.decoder with multiple arguments to decode between strings and dates:
// Decode ISO string to Date
S.decoder(S.string, S.date)("2024-01-01T00:00:00.000Z"); // Date
// Decode Date to ISO string
S.decoder(S.date, S.string)(new Date("2024-01-01T00:00:00.000Z")); // "2024-01-01T00:00:00.000Z"
ISO DateTime
S.Schema<string, string>
const schema = S.isoDateTime;
S.parser(schema)("2020-01-01T00:00:00Z"); // "2020-01-01T00:00:00Z"
S.parser(schema)("not-a-date"); // throws
Standalone string schema that validates ISO 8601 UTC datetime strings. See also ISO datetimes under Strings for more details and examples.
Instance
You can use S.instance to check that the input is an instance of a class. This is useful to validate inputs against classes that are exported from third-party libraries.
class Test {
name: string;
}
const testSchema = S.instance(Test);
const blob: any = "whatever";
S.parser(testSchema)(new Test()); // passes
S.parser(testSchema)(blob); // throws S.Error: Expected Test, received "whatever"
Meta
Use S.meta to add metadata to the resulting schema.
const documentedStringSchema = S.string.with(S.meta, {
description: "A useful bit of text, if you know what to do with it.",
});
documentedStringSchema.description; // A useful bit of text…
This can be useful for documenting fields, generating JSON, etc.
S.toJSONSchema(documentedStringSchema);
// {
// "type": "string",
// "description": "A useful bit of text, if you know what to do with it."
// }
Brand
Add a type-only symbol to an existing type so that only values produced by validation satisfy it.
Use S.brand to attach a nominal brand to a schema's output. This is a TypeScript-only marker: it does not change runtime behavior. Combine it with S.refine (or any validation) so only validated values can acquire the brand.
// Brand a string as a UserId
const userIdSchema = S.string.with(S.brand, "UserId");
type UserId = S.Infer<typeof userIdSchema>; // S.Brand<string, "UserId">
const id: UserId = S.parser(userIdSchema)("u_123"); // OK
const asString: string = id; // OK: branded value is assignable to string
// @ts-expect-error - A plain string is not assignable to a branded string
const notId: UserId = "u_123";
You can define brands for refined constraints, like even numbers:
const evenSchema = S.number
.with(S.refine, (value) => value % 2 === 0, {
error: "Expected an even number",
})
.with(S.brand, "even");
type Even = S.Infer<typeof evenSchema>; // S.Brand<number, "even">
const good: Even = S.parser(evenSchema)(2); // OK
// @ts-expect-error - number is not assignable to brand "even"
const bad: Even = 5;
For more information on branding in general, check out this excellent article from Josh Goldberg.
Custom schema
Sury might not have many built-in schemas for your use case. In this case you can create a custom schema for any TypeScript type.
- Choose a base schema which is the closest to your type. Most likely it'll be
S.instance. - Use
S.toto add a custom decode and encode logic. - Optionally, use
S.metato add customize the name of the schema and additional metadata.
const mySet = <T>(itemSchema: S.Schema<unknown, T>): S.Schema<unknown, Set<T>> =>
S.instance(Set<unknown>)
.with(S.to, S.instance(Set<T>), (input) => {
const output = new Set<T>();
input.forEach((item, index) => {
try {
output.add(S.parser(itemSchema)(item));
} catch (e) {
if (e instanceof S.Error) {
throw new Error(`At item ${index} - ${e.reason}`);
}
throw e;
}
});
return output;
})
.with(S.meta, {
name: `Set<${S.toExpression(itemSchema)}>`,
});
const numberSetSchema = mySet(S.number);
type NumberSet = S.Infer<typeof numberSetSchema>; // Set<number>
S.parser(numberSetSchema)(new Set([1, 2, 3])); // passes
S.parser(numberSetSchema)(new Set([1, 2, "3"])); // throws S.Error: At item 3 - Expected number, received "3"
S.parser(numberSetSchema)([1, 2, 3]); // throws S.Error: Expected Set<number>, received [1, 2, 3]
Recursive schemas
You can define a recursive schema in Sury. 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[];
};
const nodeSchema = S.recursive<Node>("Node", (nodeSchema) =>
S.schema({
id: S.string,
children: S.array(nodeSchema),
})
);
One type parameter is enough when the schema doesn't transform — S.recursive<Node> is S.Schema<Node, Node>. When the recursive schema transforms its input, pass both sides in S.Schema<TInput, TOutput> order:
type Row = { title: string; children: Row[] };
const rowSchema = S.recursive<unknown, Row>("Row", (rowSchema) =>
S.schema({
TITLE: S.string,
CHILDREN: S.array(rowSchema),
}).with(S.shape, (input) => ({
title: input.TITLE,
children: input.CHILDREN,
}))
);
🧠 Despite supporting recursive schema, passing cyclical data will cause an infinite loop.
Refinements
Sury lets you provide custom validation logic via refinements. Refinements let you define checks that are not expressible in the type system alone — for example, checking that a number is positive or that a string is a valid URL.
const positiveNumberSchema = S.number.with(S.refine, (value) => value > 0);
Refinement functions should return true to indicate success or false to signal failure. By default, a failed refinement throws with the message "Refinement failed".
Custom error message
Provide a custom error message via the error option:
const shortStringSchema = S.string.with(S.refine, (value) => value.length <= 255, {
error: "String can't be more than 255 characters",
});
Custom error path
When refining an object schema, you can use the path option to attach the error to a specific field:
const passwordFormSchema = S.schema({
password: S.string,
confirm: S.string,
}).with(S.refine, (data) => data.password === data.confirm, {
error: "Passwords don't match",
path: ["confirm"],
});
Chaining refinements
Refinements can be chained. Each refinement is applied in order:
const evenPositiveSchema = S.number
.with(S.refine, (val) => val > 0, { error: "Must be positive" })
.with(S.refine, (val) => val % 2 === 0, { error: "Must be even" });
The refine function is applied for both parsing and serializing.
Also, you can have an asynchronous assertion (for decoder only):
const userSchema = S.schema({
id: S.uuid.with(S.asyncDecoderAssert, async (id) => {
const isActiveUser = await checkIsActiveUser(id);
if (!isActiveUser) {
throw new Error(`The user ${id} is inactive.`);
}
}),
name: S.string,
});
type User = S.Infer<typeof userSchema>; // { id: string, name: string }
// Need to use asyncParser for schemas with async transformations
await S.asyncParser(userSchema)({
id: "1",
name: "John",
});
shape
The S.shape schema is a helper function that allows you to transform the value to a desired shape. It'll statically derive required data transformations to perform the change in the most optimal way.
⚠️ Even though it looks like you operate with a real value, it's actually a dummy proxy object. So conditions or any other runtime logic won't work. Please use
S.tofor such cases.
const circleSchema = S.number.with(S.shape, (radius) => ({
kind: "circle",
radius: radius,
}));
S.parser(circleSchema)(1); //? { kind: "circle", radius: 1 }
// Also works in reverse 🔄
S.encoder(circleSchema)({ kind: "circle", radius: 1 }); //? 1
Functions on schema
Pipelines
Conversion targets are schemas, not dedicated functions: S.json, S.jsonString, S.unknown, S.date, and S.uint8Array are ordinary schemas usable at any position in a chain.
S.decoder(from, …intermediate, to)— compile a forward pipeline from one schema to another.S.encoder(from, …intermediate, to)— compile the reverse pipeline.
Each call fuses the whole chain into a single function generated via new Function.
// Validate unknown input.
S.parser(userSchema)(data);
// Parse a JSON string, then validate.
S.decoder(S.jsonString, userSchema)(rawString);
// Encode a domain value all the way out to a JSON string.
S.encoder(userSchema, S.jsonString)(user);
// Decode a UTF-8 byte payload into text.
S.decoder(S.uint8Array, S.string)(bytes);
The same applies inside schemas via S.to. A field, an array element, or a tuple slot can be its own multi-stage chain:
const apiUser = S.schema({
// Arrives as a JSON string, which is parsed and validated as an array of addresses.
addresses: S.jsonString.with(S.to, S.array(addressSchema)),
// Arrives as bytes, decoded as UTF-8, mapped to a Date.
createdAt: S.uint8Array.with(S.to, S.string).with(S.to, S.date),
// Element-level transforms work the same way.
ids: S.array(S.string.with(S.to, S.bigint)),
});
S.to is the same compiler as S.decoder / S.encoder, applied at a single point in a larger schema. The whole tree — top-level operation plus every nested S.to — folds into one generated function.
🧠
S.parserandS.assertaren't separate primitives — they're just specializations ofS.decoderwithS.unknownon the input side.S.parser(schema)isS.decoder(S.unknown, schema).S.assert(schema, data)runs a decoder fromS.unknownthrough the schema toS.literal(true).with(S.noValidation, true)— the target is a no-op constant with validation disabled, so the compiler emits the schema's validation but no output-construction code at all. That's whyassert$ \text{is} 2–3 \times \text{faster} \text{than} $parser.
Built-in operations
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.parser | (Schema<TInput, TOutput>) => (data: unknown) => TOutput | Parses any value with the schema |
| S.asyncParser | (Schema<TInput, TOutput>) => (data: unknown) => Promise<TOutput> | 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.decoder | (Schema<TInput, TOutput>) => (TInput) => TOutput | Converts input value to the output type |
| S.asyncDecoder | (Schema<TInput, TOutput>) => (TInput) => Promise<TOutput> | Converts input value to the output type with async transforms |
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.noValidation(schema, true) 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.encoder | (Schema<TInput, TOutput>) => (TOutput) => TInput | Converts schema value to the input type |
| S.asyncEncoder | (Schema<TInput, TOutput>) => (TOutput) => Promise<TInput> | Converts schema value to the input type with async transformations |
This is literally the same as convert operations applied to the reversed schema.
For some cases you might want to simply check whether the input value is valid, without parsing it. For this there are the S.assert and S.is operations:
| Operation | Interface | Description |
|---|---|---|
| S.assert | (Schema<TInput, TOutput>, data: unknown) asserts data is TInput or (data: unknown, Schema<TInput, TOutput>) asserts data is TInput | Asserts that the input value is valid. Since the operation doesn't return a value, it's 2-3 times faster than parser depending on the schema |
| S.is | (Schema<TInput, TOutput>, data: unknown) => data is TInput or (data: unknown, Schema<TInput, TOutput>) => data is TInput | Returns true/false whether the input value is valid. Acts as a TypeScript type guard and shares the fast validate-only path with assert |
Both S.assert and S.is accept their arguments in either order, so (schema, data) and (data, schema) are equivalent and both narrow the type. There's no "correct" order to memorize — pass the schema and the data in whatever order feels natural, and it just works. This is especially handy for AI assistants, which no longer have to guess the right argument position:
const data: unknown = "abc";
// (data, schema) order
if (S.is(data, S.string)) {
// data is now typed as string
}
S.assert(data, S.string);
// data is now typed as string
// (schema, data) order — equivalent
if (S.is(S.string, data)) {
// data is now typed as string
}
S.assert(S.string, data);
// data is now typed as string
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.parser(S.string)(123));
Chaining operations
S.decoder and S.encoder accept multiple schemas to build a single fused pipeline. The first schema is the input side and the last is the output side; intermediate schemas act as stages.
// Decode a JSON string into your domain type in one pass
const parseJsonString = S.decoder(S.jsonString, userSchema);
parseJsonString('{"id":"1","name":"John"}');
// Encode your domain type to a JSON string in one pass
const stringifyUser = S.encoder(userSchema, S.jsonString);
stringifyUser({ id: "1", name: "John" });
This covers the use cases that previously needed S.compile — see the migration cheat sheet for the full mapping.
reverse
S.reverse(S.nullable(S.string));
// S.optional(S.string)
const schema = S.object((s) => s.field("foo", S.string));
S.parser(schema)({ foo: "bar" });
// "bar"
const reversed = S.reverse(schema);
S.parser(reversed)("bar");
// {"foo": "bar"}
S.parser(reversed)(123);
// throws S.error with the message: `Expected string, received 123`
Reverses the schema. This gets especially magical for schemas with transformations 🪄
to
This very powerful API allows you to coerce another data type in a declarative way. Let's say you receive a number that is passed to your system as a string. For this S.to is the best fit:
const schema = S.string.with(S.to, S.number);
S.parser(schema)("123"); //? 123.
S.parser(schema)("abc"); //? throws: Expected number, received "abc"
// Reverse works correctly as well 🔥
S.encoder(schema)(123); //? "123"
Custom transformations
You can also provide a custom transformation function to the S.to operation. This is useful when you need to perform a more complex transformation than the built-in ones.
const schema = S.string.with(
S.to,
S.number,
// Custom decode function
(string) => {
const number = parseInt(string, 10);
if (Number.isNaN(number)) {
throw new Error("Invalid number");
}
return number;
},
// Custom encode function
(number) => {
return number.toString();
}
);
S.parser(schema)("123"); //? 123
S.parser(schema)("abc"); //? throws: Invalid number
S.encoder(schema)(123); //? "123"
🧠 Prefer to use built-in
S.string.with(S.to, S.number)instead of custom transformation functions when possible.
name
const schema = S.schema({ abc: 123 }).with(S.meta, { name: "Abc" });
schema.name; // "Abc"
Used internally for readable error messages.
toExpression
S.toExpression(S.schema({ abc: 123 }));
// "{ abc: 123; }"
S.toExpression(S.name(S.string, "Address"));
// "Address"
Used internally for readable error messages.
🧠 The format subject to change
Error handling
Sury throws S.Error which is a subclass of Error class. It contains detailed information about the operation problem.
S.parser(S.schema(false))(true);
// => Throws S.Error with the following message: Expected false, received true".
You can catch the error using S.safe and S.safeAsync helpers:
const result = S.safe(() => S.parser(S.schema(false))(true));
if (result.success) {
console.log(result.value);
} else {
console.log(result.error);
}
Or the async version:
const result = await S.safeAsync(async () => {
const passed = await S.asyncParser(S.boolean)(data);
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.
Global config
Sury has a global config that can be changed to customize the behavior of the library.
defaultAdditionalItems
defaultAdditionalItems 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.global({
defaultAdditionalItems: "strict",
})
disableNanNumberValidation
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.global({
disableNanNumberValidation: true,
})