Recently I wrote a comprehensive article about a JavaScript Schema library from the future where I introduced ReScript Schema and demonstrated some truly groundbreaking features you won't find in any other library. I highly recommend checking it out first.
While the feedback was overwhelmingly positive, I noticed that despite having TypeScript support, many developers were hesitant simply because of "ReScript" in the name. So after months of dedicated development, I'm excited to present you Sury - a TypeScript-first schema validation library that brings all those amazing features while staying true to its ReScript roots 🔥
What is Sury?
It's a schema library you'll definitely want to use:
- API that's easy to read and write, similar to TS types and without noise
- Nice type inference with real types on schema hover
- The fastest parsing with explicit throws
- Convenient error handling with readable error messages
- And more after the code example ☺️
import * as S from "sury";
const filmSchema = S.schema({
id: S.bigint,
title: S.string,
tags: S.array(S.string),
rating: S.union(["G", "PG", "PG13", "R"]),
});
// On hover: S.Schema<{ id: bigint; title: string; tags: string[]; rating: "G" | "PG" | "PG13" | "R"; }, Input>
type Film = S.Output<typeof filmSchema>;
// On hover: { id: bigint; title: string; tags: string[]; rating: "G" | "PG" | "PG13" | "R"; }
S.parseOrThrow(
{
id: 1n,
title: "My first film",
tags: ["Loved"],
rating: "S",
},
filmSchema
);
// Throws S.Error with message: Failed at ["rating"]: Expected "G" | "PG" | "PG13" | "R", received "S"
// Or do it safely:
const result = S.safe(() => S.parseOrThrow(data, filmSchema));
if (result.error) {
console.log(result.error.reason);
// Expected "G" | "PG" | "PG13" | "R", received "S"
}
Honestly, I think that's already enough to make you like Sury and want to try it in your next project, or at least leave a star on GitHub ⭐
Well, probably that's still a lot to ask for, since I've only shown ~10% of the features so far 😅
Let's continue with the ecosystem talk - the most important part of any library.
Ecosystem
Standard Schema
Set aside the performance, transformations, and serialization features for now, even though they're some of the library's greatest strengths. These features become truly valuable once you have a robust ecosystem to use them with - which was the main focus of the Sury (ReScript Schema v10) release.
A major enhancement is native support for Standard Schema, which is already integrated with over 32 popular libraries. Previous versions required explicitly calling S.toStandard
, but now every Sury schema automatically implements the Standard Schema interface.
Here's an example from the newly released oRPC showing how to replace Zod with Sury:
import { ORPCError, os } from '@orpc/server'
- import { z } from 'zod'
+ import * as S from 'sury'
export const listPlanet = os
.input(
- z.object({
- limit: z.number().int().min(1).max(100).optional(),
- cursor: z.number().int().min(0).default(0),
- }),
+ S.schema({
+ limit: S.optional(S.int32.with(S.min, 1).with(S.max, 100)),
+ cursor: S.optional(S.int32.with(S.min, 0), 0),
+ })
)
.handler(async ({ input }) => {
// your list code here
return [{ id: 1, name: 'name' }]
})
And it just works - faster, smaller and more flexible 👍
You may notice the .with
calls which might look unfamiliar. This is a new feature introduced in this release that I'll definitely talk about later.
JSON Schema
But what's more important and much more widely used is JSON Schema. I used to support it only for ReScript users, but now it's time to share some gold with TypeScript users as well 🫡
// 1. Create some advanced schema with transformations
// S.to - for easy & fast coercion
// S.shape - for fields transformation
// S.meta - with examples in Output format
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,
}))
.with(S.meta, {
description: "User entity in our system",
examples: [
{
id: 0n,
name: "Dmitry",
},
],
});
// On hover:
// S.Schema<{
// id: bigint;
// name: string;
// }, {
// USER_ID: string;
// USER_NAME: string;
// }>
// 2. Use built-in S.toJSONSchema and see how everything in the Input format
// It's just asking to put itself to Fastify or any other server with OpenAPI integration 😁
S.toJSONSchema(userSchema);
// {
// type: "object",
// additionalProperties: true,
// 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",
// },
// ],
// }
Offtopic: Serialization
In the example above I used several advanced features of the library. Let's take a moment to explore one of Sury's most powerful capabilities - bidirectional schemas. Every schema can both parse and serialize data without needing separate decoders or encoders.
Let's see how this works with our previous example:
// You can use it for parsing Input to Output
S.parseOrThrow(
{
USER_ID: "0",
USER_NAME: "Dmitry",
},
userSchema
);
// { id: 0n, name: "Dmitry" }
// See how "0" is turned into 0n and fields are renamed
// And reverse the schema and use it for parsing Output to Input
S.parseOrThrow(
{
id: 0n,
name: "Dmitry",
},
S.reverse(userSchema)
);
// { USER_ID: "0", USER_NAME: "Dmitry" }
// Just use `S.reverse` and get a full-featured schema with switched `Output` and `Input` types
// Note: You can use `S.reverseConvertOrThrow(data, schema)` if you don't need to perform validation
And while we're at it, I'll quickly show why Sury is the fastest schema library out there. Sury heavily relies on JIT optimizations by inlining all validations and transformations using new Function
. It's not something new and used by other libraries like TypeBox, ArkType and even Zod@4. But they mostly do basic validations, while Sury embraces transformations as it's core primitive:
// This is how S.parseOrThrow(data, userSchema) is compiled
(i) => {
if (typeof i !== "object" || !i) {
e[3](i);
}
let v0 = i["USER_ID"],
v2 = i["USER_NAME"];
if (typeof v0 !== "string") {
e[0](v0);
}
let v1;
try {
v1 = BigInt(v0);
} catch (_) {
e[1](v0);
}
if (typeof v2 !== "string") {
e[2](v2);
}
return { id: v1, name: v2 };
};
// This is how S.reverseConvertOrThrow(data, userSchema) is compiled
(i) => {
let v0 = i["id"];
return { USER_ID: "" + v0, USER_NAME: i["name"] };
};
Neat, right? 🤯
Internal Representation
Let's go back to ecosystem talk. While Sury provides JSON Schema integration out of the box, having a good internal representation is crucial for a schema library. It enables building custom tools and growing the ecosystem. In this version, I've made significant progress by representing every schema as a discriminated union, similar to JSON Schema.
This design allows you to easily reason about and work with the Input and Output types of your schemas in your code. Here's another advanced example of Sury usage where you can use the internal representation of the input type, to create a dynamic schema for parsing environment variables:
const envSchema = <T>(schema: S.Schema<T>): S.Schema<T, string> => {
if (schema.type === "boolean") {
return S.union([
S.schema("t").with(S.to, S.schema(true)).with(S.to, schema),
S.schema("1").with(S.to, S.schema(true)).with(S.to, schema),
S.schema("f").with(S.to, S.schema(false)).with(S.to, schema),
S.schema("0").with(S.to, S.schema(false)).with(S.to, schema),
S.string.with(S.to, schema),
]);
} else if (
schema.type === "number" ||
schema.type === "bigint" ||
schema.type === "string"
) {
return S.string.with(S.to, schema);
} else {
return S.jsonString(schema);
}
};
S.parseOrThrow("1", envSchema(S.boolean)); // true
S.parseOrThrow("1", envSchema(S.schema(true))); // true
S.parseOrThrow("1", envSchema(S.number)); // 1
S.parseOrThrow("1", envSchema(S.bigint)); // 1n
S.parseOrThrow("1", envSchema(S.string)); // "1"
S.parseOrThrow("[0, 1, 2]", envSchema(S.array(S.int32))); // [0, 1, 2]
S.parseOrThrow("1", envSchema(S.boolean.with(S.shape, (input) => {
field: input,
}))); // {field: true}
S.parseOrThrow("true", envSchema(S.number)); // Failed parsing: Expected number, received "true"
This demonstrates how the schema's input type information can be used to modify the schema dynamically, while preserving the original output type that we want.
Sury Ecosystem Future
The Environment Variable example is inspired by my library ReScript EnvSafe, which enables ReScript users to safely access environment variables using Sury. Over 3 years of Sury development, I've built many tools on top of it. While some remained internal, others evolved into powerful ReScript libraries like ReScript Rest and ReScript Stripe.
I believe porting these libraries to TypeScript will make them even more powerful and accessible to a wider audience.
Furthermore, since libraries like ReScript Rest and ReScript EnvSafe are fundamental to most projects, I plan to integrate them directly into the core Sury library:
// Comming soon ↓
import * as S from "sury";
import { handler } from "@sury/fastify";
import Fastify from "fastify";
const config = S.config((s) => ({
port: s.env("PORT", S.number.with(S.port), { devFallback: 3000 }),
host: s.env("HOST", S.string, { devFallback: "localhost" }),
debug: s.arg("--debug", S.optional(S.boolean, false)),
}));
// It'll throw if the env variables are not provided
// unless NODE_ENV is "development"
const getPosts = S.route({
path: "/posts",
method: "GET",
input: (s) => ({
skip: s.query("skip", S.int32),
take: s.query("take", S.int32),
}),
responses: [(s) => s.data(S.array(postSchema))],
});
// A simple example with query params,
// but it also supports body, headers, path params
console.log(S.curl(getPosts, { skip: 0, take: 10 }));
// curl -X GET /posts?skip=0&take=10
const fastify = Fastify({
logger: true,
});
// Fastify is just an example. It can be any other framework
handler(fastify, getPosts, ({ input }) => {
console.log(input); // { skip: 0, take: 10 }
return [{ id: 1, title: "Post 1" }];
});
// A simple RPC like API. You don't need to think about the transport layer at all
fastify.listen({ port: config.port, host: config.host });
S.fetch(getPosts, { skip: 0, take: 10 });
// fetch("/posts?skip=0&take=10")
The example already works for ReScript users and would be a great addition for TypeScript users as well. In the next version of the library, I'll focus on polishing the features and making them part of the Sury ecosystem. And thanks to the tree-shakable API they won't be included in the final bundle if you don't use them.
New API Features
Coming back to the current Sury release I promised to explain some new API changes in detail.
S.with
S.number.with(S.min, 1);
From the version every schema comes with the .with
method. It's a tiny sucrifice for the tree-shakable API, but it allows to modify schemas in a more readable way. Originally you'd write:
S.min(S.number, 1);
Might not be bad with a single modifier, but what if you need to apply multiple ones?
S.optional(S.max(S.min(S.number, 1), 100), 1);
Damn, that's a lot of noise just to apply a few modifiers.
This was a problem with a modular API, so I've added a new .with
method which is as simple as:
function with(fn, ...args) {
return fn(this, ...args)
}
And it even works with custom helpers you define:
const even = (schema) =>
schema.with(S.refine, (n, s) => {
if (n % 2 !== 0) {
throw s.fail("Expected even number");
}
});
S.number.with(even);
Thanks to this, Sury has a modular API with good tree-shaking, making it 3 times smaller than Zod@4 by default, while staying very readable and using simple functions.
In the next version I'll also extend it to modify validation error messages:
// Coming soon ↓
S.string.with("Your value must be a string");
S.min
Nothing much to say here. All modifiers became polymorphic and can accept any schema as an argument:
S.min(S.int32, 3);
S.min(S.string, 3);
S.min(S.array(S.number), 3);
S.meta
Inspired by zod@4 release I added S.meta
API. It's a simple way to add metadata to your schemas:
const schema = S.schema({
foo: S.string,
}).with(S.meta, {
description: "User entity in our system",
examples: [{ foo: "bar" }],
});
You can use it with S.toJSONSchema
or directly by accessing the schema fields:
console.log(schema.description); // "User entity in our system"
console.log(schema.examples); // [{ foo: "bar" }]
S.to
Added in the previous release as S.coerce
and renamed to S.to
. But it's worth mentioning again and again.
This API quicky became the heart of Sury and allows to transform data from one type to another. The most optimised implementation under the hood and automatic serialization support.
const schema = S.string.with(S.to, S.union([S.number, S.boolean]));
S.parseOrThrow("1", schema); // 1
S.parseOrThrow("true", schema); // true
S.parseOrThrow("foo", schema); // Failed parsing: Expected number | boolean, received "foo"
S.reverseConvertOrThrow(1, schema); // "1"
S.reverseConvertOrThrow(true, schema); // "true"
Might not look like a big deal, but I'm very close to make the feature even more powerful and flexible:
// Coming soon ↓
const userSchema = S.schema({
id: S.bigint,
name: S.string,
});
S.parseOrThrow(
{
id: "1",
name: "John",
},
userSchema.with(S.to, S.jsonable)
); // { id: 1n, name: "John" }
// Automatically convert bigint to string
// and makes other fields compatible to JSON
S.reverseConvertOrThrow(
{ id: 1n, name: "John" },
userSchema.with(S.to, S.jsonable)
); // { id: "1", name: "John" }
// Instead of using JSON.stringify, inlines JSON by itself in the fastest way
What's next?
Sury RC is already out and ready to use. Might be early in some places, but the core API been used in production by many companies for almost 3 years and decently reliable.
Definetely give it a try and leave a star if you like the library!
From my side I'll continue working on the library and adding more features.
And if you have any suggestions or feedback, please let me know!
Q&A
What about ReScript support?
I primarily use ReScript and am one of the core maintainers of the language. I'll definitely continue to support it. Even though TypeScript is the main focus now, ReScript users also greatly benefit from the changes.
What about new Function
?
Sury uses new Function
under the hood. This approach makes it the fastest schema library available, but also means it cannot be used in environments that don't allow dynamic code evaluation, like CloudFlare Workers. Most users won't be affected, but might be an issue for some. Regarding safety of the approach you shouldn't be worried. Everything embeded to the generated code is escaped and thoroughly tested. Also, other libraries like TypeBox and Zod@4 use new Function
under the hood as well.
What about Zod?
I'm a big fan of Zod and find it the best choice regarding the ecosystem and developer experience it provides. Although, despite its large size, it's tailored more toward the frontend world. If you're developing a backend or an application with high throughput, you should definitely give Sury a try.
What about TypeBox?
TypeBox is another great library, but it's tailored more toward validation rather than transformations. Also, I find TypeBox's API not as readable as Sury's. But the library definitely has some great ideas to get inspired by, such as having JSON Schema as a first-class citizen. I strongly consider adding this for Sury v11.
What about X?
There are many other great libraries out there, and some provide features that Sury currently lacks, such as multi-error support and global error message customization. However, Sury stands out by excelling across many important aspects:
- Readable API
- TypeScript inference
- Performance
- Bundle size
- Tree-shakable API
- Extensibility
- Serialization
- Asynchronous parsing
- Standard Schema & JSON Schema support
I'll write a separate honest deep comparison article soon, but for now I'll leave you with a table comparing Sury with other popular libraries:
[email protected] | [email protected] | [email protected] | [email protected] | [email protected] | |
---|---|---|---|---|---|
Total size (min + gzip) | 14.1 kB | 25.9 kB | 31.4 kB | 12.6 kB | 45.9 kB |
Benchmark size (min + gzip) | 4.27 kB | 13.5 kB | 22.8 kB | 1.23 kB | 45.8 kB |
Parse with the same schema | 94,828 ops/ms | 8,437 ops/ms | 99,640 ops/ms (No transforms) | 1,721 ops/ms | 67,552 ops/ms |
Create schema & parse once | 166 ops/ms | 6 ops/ms | 111 ops/ms (No transforms) | 287 ops/ms | 11 ops/ms |
JSON Schema | S.toJSONSchema |
z.toJSONSchema |
👑 | @valibot/to-json-schema |
T.toJsonSchema |
Standard Schema | ✅ | ✅ | ❌ | ✅ | ✅ |
Eval-free | ❌ | ⭕ opt-out | ⭕ opt-in | ✅ | ⭕ opt-out |
Codegen-free (Doesn't need compiler) | ✅ | ✅ | ✅ | ✅ | ✅ |
Infered TS Type | S.Schema<{foo: string}, {foo: string}> |
z.ZodObject<{foo: z.ZodString}, {}> |
TObject<{foo: TString}> |
v.ObjectSchema<{readonly foo: v.StringSchema |
Type<{foo: string}, {}> |
Ecosystem | ⭐️⭐️ | ⭐️⭐️⭐️⭐️⭐️ | ⭐️⭐️⭐️⭐️⭐️ | ⭐️⭐️⭐️ | ⭐️⭐️ |
What about me?
I'm a full-stack developer building the fastest Block Chain indexer. I'm also a core maintainer of ReScript language and creator of many open-source libraries.
And finally, I think it's time to wrap up and get back to building Sury!
Thanks for reading and see you on X 🫡