ReScript API reference

August 17, 2026 Β· View on GitHub

β¬… Back to highlights

ReScript API reference

Table of contents

Install

npm install sury

Then add sury to bs-dependencies in your rescript.json:

{
  ...
+ "bs-dependencies": ["sury"],
}

Basic usage

// 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.meta({deprecated: true})),
})

// 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(~to=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.decodeOrThrow(~from=filmSchema, ~to=S.unknown)
// {
//   "Id": 2,
//   "Title": "Sad & sed",
//   "Rating": "PG13",
//   "Tags": ["Loved"],
//   "Age": undefined,
// }

// 5. Convert the schema to a JSON schema
let filmJSONSchema = filmSchema->S.toJSONSchema

🧠 Schemas compile to JavaScript via eval. Print the type they describe with inputExpression.

Real-world examples

API reference

The obvious ones, at a glance:

SchemaType
S.stringS.t<string>refinements ↓
S.boolS.t<bool>
S.intS.t<int>refinements ↓
S.integerS.t<float>integer without int's range ↓
S.floatS.t<float>refinements ↓
S.bigintS.t<bigint>
S.symbolS.t<Symbol.t>
S.unitS.t<unit>shorthand for S.literal()
S.nullAsUnitS.t<unit>shorthand for S.literal(Null.null)->S.to(S.unit)
S.unknownS.t<unknown>accepts any data
S.neverS.t<S.never>fails on every value
S.array(S.string)S.t<array<string>>

The rest have their own sections below.

string

S.t<string>

let schema = S.string

"Hello World!"->S.parseOrThrow(~to=schema)
// "Hello World!"

The S.string schema represents a data that is a string. It can be further constrainted with the following utility methods.

Sury includes a handful of string-specific refinements and transforms:

S.string->S.maxLength(5) // Expected string.length <= 5
S.string->S.minLength(5) // Expected string.length >= 5
S.string->S.length(5) // Expected string.length == 5
S.string->S.nonEmpty // Expected string.length >= 1
S.string->S.pattern(%re(`/[0-9]/`)) // Invalid pattern

S.string->S.trim // trim whitespaces

For format-specific validation, use the standalone schemas β€” see String formats below.

For ISO 8601 UTC datetime strings use the dedicated standalone S.isoDateTime schema β€” 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.

String formats

The JSON Schema string format vocabulary, as standalone schemas:

S.email // Email address
S.idnEmail // Internationalized email address
S.uuid // UUID
S.cuid // CUID
S.uri // URI β€” a scheme is required
S.uriReference // URI or relative reference
S.uriTemplate // URI Template
S.iri // IRI β€” a URI with Unicode allowed
S.iriReference // IRI or relative reference
S.hostname // Host name
S.idnHostname // Internationalized host name
S.ipv4 // IPv4 address
S.ipv6 // IPv6 address
S.isoDate // Calendar date
S.isoTime // Time of day
S.isoDateTime // UTC timestamp
S.duration // Duration
S.jsonPointer // JSON Pointer
S.relativeJsonPointer // Relative JSON Pointer

Each survives a round trip through S.toJSONSchema and S.fromJSONSchema.

A format checks syntax, not safety. Every one is exactly as strict as its spec, so a well-formed value passes even when it isn't one you want to accept:

"javascript:alert(1)"->S.assertOrThrow(~to=S.uri) // passes β€” a valid URI
"169.254.169.254"->S.assertOrThrow(~to=S.hostname) // passes β€” a valid host name
"//evil.com"->S.assertOrThrow(~to=S.uriReference) // passes β€” a valid reference

When you want a security decision rather than a syntax check, compose one. The extra constraint rides along into the JSON Schema, so it stays honest:

let httpsOnly = S.uri->S.pattern(%re(`/^https:\/\//`))
// { type: "string", format: "uri", pattern: "^https:\\/\\/" }

Two worth knowing before you pick one:

  • S.url is not S.uri. S.url is an instance of the JS URL class, the way S.date is a Date β€” use it when you want the parsed object and its .host / .pathname. S.uri validates a string and leaves it a string.
  • S.uriReference is usually the one you want for a link field. S.uri requires a scheme, so it rejects /dashboard.

Custom error messages

Built-in refinements accept an optional ~message argument for a custom error message:

S.string->S.nonEmpty(~message="String can't be empty")
S.string->S.length(5, ~message="SMS code should be 5 digits long")
S.string->S.pattern(%re(`/^\d+$/`), ~message="Must be numeric")

For standalone schemas or more control, use S.meta with the errorMessage field:

// Override a specific constraint message
S.email->S.meta({errorMessage: {format: "Must be a valid email"}})

// Use catchAll as a fallback for any constraint
S.email->S.meta({errorMessage: {catchAll: "Invalid input"}})

// Reset error messages (removes all overrides)
schema->S.meta({errorMessage: {}})

Available fields: format, type_, minimum, maximum, minLength, maxLength, minItems, maxItems, minSize, maxSize, pattern, catchAll (encoded as _).

ISO datetimes

S.isoDateTime is a standalone string schema (S.t<string>) that validates ISO 8601 UTC datetime strings: no timezone offsets allowed, with arbitrary sub-second decimal precision.

let schema = S.isoDateTime
// schema has the type S.t<string>

"2020-01-01T00:00:00Z"->S.parseOrThrow(~to=schema) // pass
"2020-01-01T00:00:00.123Z"->S.parseOrThrow(~to=schema) // pass
"2020-01-01T00:00:00.123456Z"->S.parseOrThrow(~to=schema) // pass (arbitrary precision)
"2020-01-01T00:00:00+02:00"->S.parseOrThrow(~to=schema) // fail (no offsets allowed)

To decode an ISO datetime string into a Date.t, combine it with S.to(S.date):

let schema = S.string->S.to(S.date)
// schema has the type S.t<Date.t>

int

S.t<int>

The S.int schema represents a data that is an integer.

Sury includes some of int-specific refinements:

S.int->S.lte(5) // Expected int32 <= 5
S.int->S.gte(5) // Expected int32 >= 5
S.int->S.lt(5) // Expected int32 < 5
S.int->S.gt(5) // Expected int32 > 5
S.int->S.multipleOf(2) // Expected int32 % 2
S.port // Standalone port schema

They all work on S.float and S.bigint too. A numeric format carries its own range, so a bound outside it fails where it's written rather than building a schema nothing satisfies:

S.int->S.gte(3000000000)
// int32 >= 3000000000 contradicts int32 <= 2147483647

S.integer is an integer without that range, typed S.t<float> since one can exceed ReScript's int:

S.integer->S.gte(5.) // Expected integer >= 5

float

S.t<float>

The S.float schema represents a data that is a number.

Sury includes some of float-specific refinements:

S.float->S.lte(5.) // Expected number <= 5
S.float->S.gte(5.) // Expected number >= 5
S.float->S.lt(5.) // Expected number < 5
S.float->S.gt(5.) // Expected number > 5

option

S.t<'value> => S.t<option<'value>>

let schema = S.option(S.string)

"Hello World!"->S.parseOrThrow(~to=schema)
// Some("Hello World!")
%raw(`undefined`)->S.parseOrThrow(~to=schema)
// None

The S.option schema represents a data of a specific type that might be undefined.

Option.getOr

(S.t<option<'value>>, 'value) => S.t<'value>

let schema = S.option(S.string)->S.Option.getOr("Hello World!")

%raw(`undefined`)->S.parseOrThrow(~to=schema)
// "Hello World!"
"Goodbye World!"->S.parseOrThrow(~to=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 on Object.s type.

Option.getOrWith

(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(~to=schema)
// ["Hello World!"]
["Goodbye World!"]->S.parseOrThrow(~to=schema)
// ["Goodbye World!"]

Also you can use Option.getOrWith for lazy evaluation of the default value.

null

S.t<'value> => S.t<null<'value>>

let schema = S.null(S.string)

"Hello World!"->S.parseOrThrow(~to=schema)
// Value("Hello World!")
%raw(`null`)->S.parseOrThrow(~to=schema)
// Null

The S.null schema represents a data of a specific type that might be null.

nullAsOption

S.t<'value> => S.t<option<'value>>

let schema = S.nullAsOption(S.string)

"Hello World!"->S.parseOrThrow(~to=schema)
// Some("Hello World!")
%raw(`null`)->S.parseOrThrow(~to=schema)
// None

The S.nullAsOption schema represents a data of a specific type that might be null.

🧠 Since S.nullAsOption transforms value into option type, you can use Option.getOr/Option.getOrWith for it as well.

nullable

S.t<'value> => S.t<Nullable.t<'value>>

let schema = S.nullable(S.string)

"Hello World!"->S.parseOrThrow(~to=schema)
// Some("Hello World!")
%raw(`null`)->S.parseOrThrow(~to=schema)
// Null
%raw(`undefined`)->S.parseOrThrow(~to=schema)
// Undefined

The S.nullable schema represents a data of Nullable.t that might be null or undefined.

nullableAsOption

S.t<'value> => S.t<option<'value>>

The same as S.nullable, but returns option type instead of Nullable.t. When encoding, it will return undefined for None values.

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) // Or use S.nullAsUnit
let undefinedSchema = S.literal() // Or use S.unit

// Uses Number.isNaN to match NaN literals
let nanSchema = S.literal(Float.Constants.nan)->S.shape(_ => ()) // For NaN literals I recomment adding S.shape 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 encoding.

object

(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 encoding
{"x": 1, "y": -4}->S.parseOrThrow(~to=pointSchema)
{x: 1, y: -4}->S.decodeOrThrow(~from=pointSchema, ~to=S.unknown)

The object schema represents an object value, that can be transformed into any ReScript value. Here are some examples:

Transform object field names

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(~to=schema)
// {id: 1, name: "John"}
{id: 1, name: "John"}->S.decodeOrThrow(~from=schema, ~to=S.unknown)
// {"USER_ID": 1, "USER_NAME": "John"}

Transform to a structurally typed object

// 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),
})

Transform to a tuple

// 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(~to=schema)
// (1, "John")

The same schema also works for encoding:

(1, "John")->S.decodeOrThrow(~from=schema, ~to=S.unknown)
// {"USER_ID":1,"USER_NAME":"John"}

Transform to a variant

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(~to=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 encoding:

Circle({radius: 1})->S.decodeOrThrow(~from=schema, ~to=S.unknown)
// {
//   "kind": "circle",
//   "radius": 1,
// }

s.flatten

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,
  }
})

s.nested

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.

Object destructuring

It's possible to destructure object field schemas inside of definition, as in the s.flatten example above.

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 for s.nested will expect an object with the type {id: string, data: {name: option<string>, age: int}}.

strict

S.t<'value> => S.t<'value>

// Represents an object without fields
let schema = S.object(_ => ())->S.strict

{
  "someField": "value",
}->S.parseOrThrow(~to=schema)
// throws S.error with the message: `Unrecognized key  "unknownKey"`

By default Sury 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.global function:

S.global({
  defaultAdditionalItems: Strict,
})

strip

S.t<'value> => S.t<'value>

// Represents an object with any fields
let schema = S.object(_ => ())->S.strip

{
  "someField": "value",
}->S.parseOrThrow(~to=schema)
// ()

You can use the S.strip function to reset a 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.

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

schema

(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's going to be the same as:
// S.string->S.shape(string => Text(string))

let multySelectSchema = S.schema(s => MultiSelect(s.matches(S.array(S.string))))
// The same as:
// S.array(S.string)->S.shape(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, while S.object/S.tuple are more flexible and require you to describe the schema explicitly.

shape

(S.t<'value>, 'value => 'shape) => S.t<'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.to with ~custom codecs for such cases.

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.shape(radius => Circle({radius: radius}))

1->S.parseOrThrow(~to=schema)
// Circle({radius: 1.})

The same schema also works for encoding:

Circle({radius: 1})->S.decodeOrThrow(~from=schema, ~to=S.unknown)
// 1

union

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.

🧠 Members are matched in the order they are passed to S.union β€” the first one that fits the value wins.

It's also available as S.anyOf, matching the JSON Schema keyword it maps to.

// 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(~to=shapeSchema)
// Circle({radius: 1.})
Square({x: 2.})->S.decodeOrThrow(~from=shapeSchema, ~to=S.unknown)
// {
//   "kind": "square",
//   "x": 2,
// }

Enums

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(~to=schema)
// Draw

Also, you can use S.enum as a shorthand for the use case above.

let schema = S.enum([Win, Draw, Loss])

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:

let schema = S.json->S.to(S.union([S.bigint->S.castToUnknown, S.string->S.castToUnknown]))

"123"->S.parseOrThrow(~to=schema) // 123n β€” the bigint member comes first
"abc"->S.parseOrThrow(~to=schema) // "abc" β€” not a valid bigint, so the string member takes it
true->S.parseOrThrow(~to=schema) // raises β€” no member accepts a bool

Notice that true wasn't converted to "true", even though bool β†’ 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:

let schema =
  S.union([S.bigint->S.castToUnknown, S.bool->S.castToUnknown])->S.to(S.string)

123n->S.parseOrThrow(~to=schema) // "123"
true->S.parseOrThrow(~to=schema) // "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: with no counterpart of its own, S.option's undefined may pair with S.null's null on the other side, and vice versa:

S.union([S.string->S.castToUnknown, S.float->S.castToUnknown])
->S.to(S.union([S.float->S.castToUnknown, S.string->S.castToUnknown])) // βœ… both pass through
S.option(S.string)->S.to(S.null(S.string)) // βœ… None <-> null
S.option(S.string)->S.to(S.null(S.bool)) // ❌ string has no counterpart

Good to know:

  • Formats count as distinct types: S.int won't match a plain S.float member, and S.json won't match S.string.
  • Nested unions are treated as one flat union: S.union([S.string, S.union([S.float, S.bool])]) has three members.
  • When a value fails a member β€” wrong type, failed refinement, or an error raised inside it β€” the next member gets a try. Only when all members fail does the union raise, 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 operation compilation β€” 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 float? Both readings are sensible, so Sury makes you pick:

S.string->S.to(S.union([S.float->S.castToUnknown, S.string->S.castToUnknown]))
// 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 float when possible, keep the string otherwise:
let asFloat = S.string->S.to(
  S.union([S.string->S.to(S.float)->S.castToUnknown, S.string->S.castToUnknown]),
)
"123"->S.parseOrThrow(~to=asFloat) // 123.
"abc"->S.parseOrThrow(~to=asFloat) // "abc"

// Or pass strings through, never producing a float:
let asString = S.string->S.to(
  S.union([S.never->S.to(S.float)->S.castToUnknown, S.string->S.castToUnknown]),
)
"123"->S.parseOrThrow(~to=asString) // "123"
"abc"->S.parseOrThrow(~to=asString) // "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.castToUnknown, S.float->S.castToUnknown])->S.to(
  S.union([
    S.float->S.castToUnknown,
    S.string->S.castToUnknown,
    S.bool->S.castToUnknown,
  ]),
)
// Invalid operation: … boolean has no same-type variant on the other side.
S.option(S.string)->S.to(S.null(S.bool)) // ❌ string doesn't match boolean
S.option(S.string)->S.to(S.null(S.string->S.to(S.bool))) // βœ…

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.bool->S.to(S.union([S.string->S.castToUnknown, S.symbol->S.castToUnknown]))
// ❌ bool -> symbol isn't supported
S.union([S.bool->S.castToUnknown, S.symbol->S.castToUnknown])->S.to(S.string)
// ❌ symbol -> string isn't supported
S.bool->S.to(
  S.union([S.string->S.castToUnknown, S.never->S.to(S.symbol)->S.castToUnknown]),
) // βœ… symbol marked unreachable

🧠 Union conversion always validates every member, so transformed unions stay consistent across decode and encode.

list

S.t<'value> => S.t<list<'value>>

let schema = S.list(S.string)

["Hello", "World"]->S.parseOrThrow(~to=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.

compactColumns

S.t<'value> => S.t<array<array<'value>>>

let schema = S.compactColumns(S.schema(s => {
  id: s.matches(S.string),
  name: s.matches(S.nullAsOption(S.string)),
  deleted: s.matches(S.bool),
}))

[{id: "0", name: Some("Hello"), deleted: false}, {id: "1", name: None, deleted: true}]->S.decodeOrThrow(~from=schema, ~to=S.unknown)
// [["0", "1"], ["Hello", null], [false, true]]

It flattens a nested array of objects into arrays of values by field β€” the layout described in Boosting Postgres INSERT Performance by 2x With UNNEST.

Checkout the compiled code yourself:
(i) => {
  let v1 = [new Array(i.length), new Array(i.length), new Array(i.length)];
  for (let v0 = 0; v0 < i.length; ++v0) {
    let v3 = i[v0];
    try {
      let v4 = v3["name"];
      if (v4 === void 0) {
        v4 = null;
      }
      v1[0][v0] = v3["id"];
      v1[1][v0] = v4;
      v1[2][v0] = v3["deleted"];
    } catch (v2) {
      if (v2 && v2.s === s) {
        v2.path = "" + "[\"'+v0+'\"]" + v2.path;
      }
      throw v2;
    }
  }
  return v1;
};

tuple

(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 encoding
["point", 1, -4]->S.parseOrThrow(~to=pointSchema)
{ x: 1, y: -4 }->S.decodeOrThrow(~from=pointSchema, ~to=S.unknown)

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:

tuple1 - tuple3

(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(~to=schema)
// ("a", 1, true)

dict

S.t<'value> => S.t<dict<'value>>

let schema = S.dict(S.string)

{
  "foo": "bar",
  "baz": "qux",
}->S.parseOrThrow(~to=schema)
// dict{foo: "bar", baz: "qux"}

The dict schema represents a dictionary of data of a specific type.

date

S.t<Js.Date.t>

let schema = S.date

Date.fromString("2024-01-01T00:00:00Z")->S.parseOrThrow(~to=schema) // passes
%raw(`new Date("invalid")`)->S.parseOrThrow(~to=schema) // throws - Invalid Date
%raw(`"2024-01-01"`)->S.parseOrThrow(~to=schema) // throws - not a Date instance

The S.date schema validates that the input is a Date instance and rejects Invalid Date.

Unlike S.isoDateTime (which validates ISO datetime strings) and S.string->S.to(S.date) (which decodes ISO strings into Date objects), S.date validates existing Date instances directly.

You can use S.to to decode between strings and dates:

// Decode ISO string to Date
let schema = S.string->S.to(S.date)
"2024-01-01T00:00:00.000Z"->S.parseOrThrow(~to=schema) // Date

// Encode Date to ISO string
Date.fromString("2024-01-01T00:00:00.000Z")->S.decodeOrThrow(~from=schema, ~to=S.unknown) // "2024-01-01T00:00:00.000Z"

isoDateTime

S.t<string>

let schema = S.isoDateTime

"2020-01-01T00:00:00Z"->S.parseOrThrow(~to=schema) // "2020-01-01T00:00:00Z"
"not-a-date"->S.parseOrThrow(~to=schema) // throws

Standalone string schema that validates ISO 8601 UTC datetime strings. See also ISO datetimes under Strings for more details and examples.

instance

S.t<instance>

let schema: S.t<Set.t<string>> = S.instance(%raw(`Set`))->Obj.magic;

The S.instance schema represents an instance of a class. Requires some type casting to make it work, but better than S.unknown as a building block for more complex schemas.

blob

S.t<Js.Blob.t>

S.blob // Expected Blob
S.blob->S.maxSize(1_000_000) // Expected Blob.size <= 1000000
S.blob->S.minSize(1) // Expected Blob.size >= 1
S.blob->S.size(2) // Expected Blob.size == 2
S.blob->S.maxSize(1_000_000, ~message="Too large")

S.minSize, S.maxSize and S.size bound the size in bytes. They work on any S.instance schema with a .size, counting entries rather than bytes.

Strings and arrays use S.minLength/S.maxLength/S.length instead. A lower bound of 0 is dropped; a negative one is an error.

file

S.t<Js.File.t>

let schema = S.file->S.maxSize(1_000_000)

%raw(`new File(["hi"], "a.txt")`)->S.parseOrThrow(~to=schema) // passes
%raw(`new Blob(["hi"])`)->S.parseOrThrow(~to=schema) // throws - Expected File, received Blob

A File is a Blob, so it also satisfies S.blob β€” not the other way round. It takes the same size bounds.

json

S.t<JSON.t>

let schema = S.json

`"abc"`->S.parseOrThrow(~to=schema)
// "abc" of type JSON.t

The S.json schema represents a data that is compatible with JSON.

jsonString

S.t<string>

let schema = S.jsonString->S.to(S.int)

"123"->S.parseOrThrow(~to=schema)
// 123

The S.jsonString schema represents JSON string.

There's also S.jsonStringWithSpace to configure space in the JSON string during encoding.

meta

(S.t<'value>, S.meta) => S.t<'value>

Use S.meta to add a metadata to the resulting schema.

let documentedStringSchema = S.string
  ->S.meta({description: "A useful bit of text, if you know what to do with it."})

(documentedStringSchema->S.untag).description // A useful bit of text…

This can be useful for documenting fields, generating JSON, etc.

schema->S.toJSONSchema
// {
//   "type": "string",
//   "description": "A useful bit of text, if you know what to do with it."
// }

recursive

(string, t<'value> => t<'value>) => t<'value>

You can define a recursive schema in Sury.

type rec node = {
  id: string,
  children: array<node>,
}

let nodeSchema = S.recursive("Node", 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(~to=nodeSchema)
// {
//   id: "1",
//   children: [{id: "2", children: []}, {id: "3", children: [{id: "4", children: []}]}],
// }

The same schema works for encoding:

{
  id: "1",
  children: [{id: "2", children: []}, {id: "3", children: [{id: "4", children: []}]}],
}->S.decodeOrThrow(~from=nodeSchema, ~to=S.unknown)
// {
//   "Id": "1",
//   "Children": [
//     {"Id": "2", "Children": []},
//     {"Id": "3", "Children": [{"Id": "4", "Children": []}]},
//   ],
// }

You can also use asynchronous parser:

let paramsSchema = S.schema(s => {name: s.matches(S.string)})

let nodeSchema = S.recursive("Node", nodeSchema => {
  S.object(s => {
    params: s.field(
      "Id",
      S.string->S.to(paramsSchema, ~custom={decode: Async(id => loadParams(~id)), encode: Never}),
    ),
    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 will cause an infinite loop.

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.

  1. Choose a base schema which is the closest to your type. Most likely it'll be S.instance.
  2. Use S.to with ~custom codecs to add a custom decoder and encoder.
  3. Optionally, use S.meta to add customize the name of the schema and additional metadata.
let mySet = itemSchema => {
  S.instance(%raw(`Set`))
  ->S.to(
    // The escape hatch earns its keep here: no schema carries `Set.t<'item>`,
    // and `S.any` is what lets `mySet` return `S.t<Set.t<'item>>`. Reach for a
    // real target everywhere it exists β€” see `to` with `~custom`.
    S.any,
    ~custom={
      decode: Sync(
        input => {
          let output = Set.make()
          input
          ->Obj.magic
          ->Set.forEach(
            item => {
              output->Set.add(S.parseOrThrow(item, ~to=itemSchema))
            },
          )
          output
        },
      ),
      encode: Never,
    },
  )
  ->S.meta({name: `Set.t<${S.inputExpression(itemSchema)}>`})
}

let intSetSchema = mySet(S.int)

S.parseOrThrow(%raw(`new Set([1, 2, 3])`), ~to=intSetSchema) // passes
S.parseOrThrow(%raw(`new Set([1, 2, "3"])`), ~to=intSetSchema) // throws S.Error: Expected int32, received "3"
S.parseOrThrow(%raw(`[1, 2, 3]`), ~to=intSetSchema) // throws S.Error: Expected Set.t<int32>, received [1, 2, 3]

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 email address.

refine

(S.t<'value>, 'value => bool, ~error: string=?, ~path: array<string>=?) => S.t<'value>

let positiveNumberSchema = S.int->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 labeled argument:

let shortStringSchema = S.string->S.refine(
  value => value->String.length <= 255,
  ~error="String can't be more than 255 characters",
)

Custom error path

When refining an object schema, you can use the ~path labeled argument to attach the error to a specific field:

let passwordFormSchema = S.object(s => {
  "password": s.field("password", S.string),
  "confirm": s.field("confirm", S.string),
})->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:

let evenPositiveSchema = S.int
  ->S.refine(value => value > 0, ~error="Must be positive")
  ->S.refine(value => mod(value, 2) === 0, ~error="Must be even")

The refine function is applied for both parsing and encoding.

Transforms

Sury allows to augment a conversion with custom logic, letting you transform the value during parsing and encoding. This is most commonly used for mapping the value to more convenient data-structures.

to with ~custom

(S.t<'from>, S.t<'to>, ~custom: S.codecs<'from, 'to>=?) => S.t<'to>

When no built-in conversion fits, pass your own coders:

let intToString = schema =>
  schema->S.to(
    S.string,
    ~custom={
      decode: Sync(int => int->Int.toString),
      encode: Sync(
        string =>
          switch string->Int.fromString {
          | Some(int) => int
          | None => JsError.make("Can't convert string to int")->JsError.throw
          },
      ),
    },
  )

Each direction is one of:

Sync(fn)   // a coder
Async(fn)  // a coder returning a promise, run with parseAsyncOrThrow
Auto       // keep the built-in conversion for this direction
Never      // this direction is impossible, fail when an operation needs it
// Trim on decode, built-in validation on encode
S.string->S.to(S.string, ~custom={decode: Sync(String.trim), encode: Auto})

// Load a user by id
let userSchema = S.schema(s => {id: s.matches(S.uuid), name: s.matches(S.string)})

S.uuid->S.to(
  userSchema,
  ~custom={decode: Async(userId => loadUser(~userId)), encode: Sync(user => user.id)},
)

Describe what you decode into. The target is what validates the coder's result, types the output, and exports to JSON Schema.

🧠 S.any accepts anything, so it checks nothing about what a coder returns. It's the escape hatch for a value no schema can describe β€” reach for it last, not first.

A coder fails by throwing, and the path it was reached through is prepended:

"abc"->S.decodeOrThrow(~from=S.int->intToString, ~to=S.unknown)
// Can't convert string to int

Any exception works, a ReScript one (throw(Failure("…"))) included, but only a JS error carries a message, so anything else is reported by its structure. To name a path or the schemas involved, build the error and throw that:

S.Error.make(
  InvalidInput({
    reason: "Can't convert string to int",
    path: S.Path.empty,
    expected: S.unknown,
    received: S.unknown,
  }),
)->S.Error.throw

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. Describe the shape of the data at each stage with ~from and ~to, and Sury compiles the whole pipeline into a single function via new Function.

// Validate any input value.
data->S.parseOrThrow(~to=userSchema)

// Parse a JSON string, then validate.
rawString->S.decodeOrThrow(~from=S.jsonString, ~to=userSchema)

// Encode a domain value all the way out to a JSON string.
user->S.decodeOrThrow(~from=userSchema, ~to=S.jsonString)

// Pre-compile pipelines once, call them many times.
let parseJsonUser = S.decoder(~from=S.jsonString, ~to=userSchema)
let stringifyUser = S.decoder(~from=userSchema, ~to=S.jsonString)

The same pipeline idea works inside schemas via S.to. A field, an array element, a tuple slot β€” any nested schema can be its own multi-stage chain:

let apiUserSchema = S.schema(s =>
  {
    // Arrives as a JSON string, which is parsed and validated as an array of addresses.
    "addresses": s.field("addresses", S.jsonString->S.to(S.array(addressSchema))),

    // Arrives as bytes, decoded as UTF-8, mapped to a Date.
    "createdAt": s.field("createdAt", S.uint8Array->S.to(S.string)->S.to(S.date)),

    // Element-level transforms work the same way.
    "ids": s.field("ids", S.array(S.string->S.to(S.bigint))),
  }
)

S.to is the same compiler as S.decoder and S.decodeOrThrow, just used at a single point in a larger schema. The whole tree β€” top-level operation plus every nested S.to β€” still folds into one generated function, so deep pipelines stay free of runtime overhead.

🧠 S.parseOrThrow and S.assertOrThrow aren't separate primitives β€” they're just specializations of S.decodeOrThrow with S.unknown on the input side. data->S.parseOrThrow(~to=schema) is data->S.decodeOrThrow(~from=S.unknown, ~to=schema). data->S.assertOrThrow(~to=schema) runs a decoder from S.unknown through the schema to S.literal(true)->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 why assertOrThrow$ \text{is} 2–3 \times \text{faster} \text{than} $parseOrThrow.

Built-in operations

The library provides a bunch of built-in operations that can be used to parse, decode, and assert values.

Parsing validates the input value against the schema and transforms it to the expected output type:

OperationInterfaceDescription
S.parseOrThrow('any, ~to: S.t<'value>) => 'valueParses any value with the schema
S.parseAsyncOrThrow('any, ~to: S.t<'value>) => promise<'value>Parses any value with the schema having async transformations

Decoding transforms between schemas without input validation. Be careful, since the input type is not checked:

OperationInterfaceDescription
S.decodeOrThrow('from, ~from: S.t<'from>, ~to: S.t<'to>) => 'toDecodes a value from one schema to another
S.decodeAsyncOrThrow('from, ~from: S.t<'from>, ~to: S.t<'to>) => promise<'to>Async version of decodeOrThrow

Common decode patterns:

// Parse JSON value
data->S.decodeOrThrow(~from=S.json, ~to=schema)

// Parse JSON string
data->S.decodeOrThrow(~from=S.jsonString, ~to=schema)

// Encode to unknown
data->S.decodeOrThrow(~from=schema, ~to=S.unknown)

// Encode to JSON
data->S.decodeOrThrow(~from=schema, ~to=S.json)

// Encode to JSON string
data->S.decodeOrThrow(~from=schema, ~to=S.jsonString)

// Encode to JSON string with space
data->S.decodeOrThrow(~from=schema, ~to=S.jsonStringWithSpace(2))

Also, you can use S.noValidation helper to turn off type validations for the schema even when it's used with a parse operation.

Asserting validates the input value without returning a transformed result:

OperationInterfaceDescription
S.assertOrThrow('any, ~to: 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
S.assertAsyncOrThrow('any, ~to: S.t<'value>) => promise<()>Async version of assertOrThrow

All operations either return the output value or throw an exception which you can catch with try/catch block:

try true->S.parseOrThrow(~to=schema) catch {
| S.Error(error) => Console.log(error.message)
}

parser / asyncParser

S.parser: (~through: array<S.t<unknown>>=?, ~to: S.t<'value>) => 'any => 'value
S.asyncParser: (~through: array<S.t<unknown>>=?, ~to: S.t<'value>) => 'any => promise<'value>

Returns a compiled parse function that validates input and transforms it to the schema's output type. This is the most performant way to parse values repeatedly. Use ~through to chain intermediate schemas.

let parse = S.parser(~to=S.string)

parse("Hello world!")
// "Hello world!"

// Async version for schemas with async transformations
let parseAsync = S.asyncParser(~to=schemaWithAsyncTransform)

decoder / asyncDecoder

S.decoder: (~from: S.t<'from>, ~through: array<S.t<unknown>>=?, ~to: S.t<'to>) => 'from => 'to
S.asyncDecoder: (~from: S.t<'from>, ~through: array<S.t<unknown>>=?, ~to: S.t<'to>) => 'from => promise<'to>

Returns a compiled decode function that transforms values from one schema to another. Use ~through to chain intermediate schemas.

// Compile an encoder
let encode = S.decoder(~from=schema, ~to=S.unknown)

// Compile a JSON decoder
let decodeJson = S.decoder(~from=S.json, ~to=schema)

// Compile a JSON string encoder
let toJsonString = S.decoder(~from=schema, ~to=S.jsonString)

// Compile an async decoder
let decodeAsync = S.asyncDecoder(~from=S.json, ~to=schema)

decoder1 / asyncDecoder1

S.decoder1: S.t<'value> => unknown => 'value
S.asyncDecoder1: S.t<'value> => unknown => promise<'value>

Returns a compiled decode function for a single schema, transforming from the schema's input type to its output type. This is useful for schemas with internal transformations.

let schema = S.array(S.nullAsOption(S.string))
let decode = S.decoder1(schema)

// Input: array<Js.nullable<string>> (schema input)
// Output: array<option<string>> (schema output)
decode(%raw(`["foo", null, "bar"]`))
// [Some("foo"), None, Some("bar")]

reverse

(S.t<'value>) => S.t<'value>

S.nullAsOption(S.string)->S.reverse
// S.option(S.string)
let schema = S.object(s => s.field("foo", S.string))

{"foo": "bar"}->S.parseOrThrow(~to=schema)
// "bar"

let reversed = schema->S.reverse

"bar"->S.parseOrThrow(~to=reversed)
// {"foo": "bar"}

123->S.parseOrThrow(~to=reversed)
// throws S.error with the message: `Expected string, received 123`

Reverses the schema. This gets especially magical for schemas with transformations πŸͺ„

to

(S.t<'from>, S.t<'to>) => S.t<'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:

let schema = S.string->S.to(S.float)

"123"->S.parseOrThrow(~to=schema) //? 123.
"abc"->S.parseOrThrow(~to=schema) //? throws: Expected number, received "abc"

// Reverse works correctly as well πŸ”₯
123.->S.decodeOrThrow(~from=schema, ~to=S.unknown) //? "123"

name

let schema = S.literal({"abc": 123})->S.meta({name: "Abc"})

(schema->S.untag).name // "Abc"

Used internally for readable error messages.

inputExpression

(S.t<'value>) => string

S.literal({"abc": 123})->S.inputExpression
// "{ "abc": 123 }"

S.string->S.meta({name: "Address"})->S.inputExpression
// "Address"

Used internally for readable error messages.

🧠 The format is subject to change

outputExpression

(S.t<'value>) => string

let schema = S.string->S.to(S.int)

schema->S.inputExpression
// "string"

schema->S.outputExpression
// "int32"

The same expression for the schema's output type.

🧠 The format is subject to change

toString

(unit) => string on the untagged schema

(S.string->S.untag).toString()
// "Schema<string>"

(S.string->S.to(S.int)->S.untag).toString()
// "Schema<string, int32>"

Both sides at once, in the order the type declares them β€” Schema<TInput, TOutput> β€” with the second parameter dropped when the two sides match.

Console.log(schema) deliberately still shows the internal schema shape, which is usually what you want when you're inspecting one. Call toString when you want the expression.

The output side is derived through reverse, so nested transforms are reported correctly:

(S.array(S.string->S.to(S.int))->S.untag).toString()
// "Schema<string[], int32[]>"

🧠 The format is subject to change

noValidation

(S.t<'value>, bool) => S.t<'value>

let schema = S.object(s => s.field("abc", S.int))->S.noValidation(true)

{
  "abc": 123,
}->S.parseOrThrow(~to=schema) // This doesn't have `if (typeof i !== "object" || !i) {` check. But field types are still validated.
// 123

Removes validation for the provided schema. Nested schemas are not affected.

This can be useful to optimise S.object parsing when you construct the input data yourself.

Standard Schema

Every schema implements the Standard Schema spec (and its JSON Schema extension) via ~standard, typed by the StandardSchema module and readable through S.untag:

let standard = (S.string->S.untag).standard

standard.validate("abc") // {value: "abc"}

S.enableStandardJSONSchema() // Once, to opt into jsonSchema (keeps toJSONSchema tree-shakeable otherwise)
(standard.jsonSchema->Option.getUnsafe).input({target: StandardSchema.JsonSchema.Draft07})
// {type: "string", $schema: "http://json-schema.org/draft-07/schema#"}

Error handling

Sury throws S.error error containing detailed information about the validation problems.

let schema = S.literal(false)

true->S.parseOrThrow(~to=schema)
// throws S.error with the message: `Expected false, received true`

If you want to handle the error, the best way to use try/catch block:

try true->S.parseOrThrow(~to=schema) catch {
| S.Exn(error) => Console.log(error.message)
}

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,
})