When it comes to open source, there are different ways you can contribute, all of which are valuable. Here's few guidelines that should help you as you prepare your contribution.
Before you start working on a contribution, create an issue describing what you want to build. It's possible someone else is already working on something similar, or perhaps there is a reason that feature isn't implemented. The maintainers will point you in the right direction.
The following steps will get you setup to contribute changes to this repo:
- Fork this repo.
- Clone your forked repo:
git clone git@github.com:{your_username}/sury.git - Install pnpm if not available
npm i -g pnpm@9.0.5 - Run
pnpm ito install dependencies. - Run
pnpm testinpackages/suryfor tests (it builds the entry bundle and compiles the ReScript bindings first). Usepnpm resif you want the ReScript compiler in watch mode while editingS.res.
The implementation lives in packages/sury/src/*.ts (see CLAUDE.md for the module layout); src/S.res is a thin ReScript bindings module on top of the same runtime.
This section describes the internal architecture of Sury to help with understanding and contributing to the codebase.
The internal representation of a type schema, containing:
tag: Type identifier (e.g.,stringTag,objectTag,arrayTag)decoder: Builder function for input validation (type checking)encoder: Builder function for converting from different schema typesparser: Builder function for transformations after decoding (used byS.shape,S.to)serializer: Builder function for reverse transformationsinputRefiner: User validations run on the typed input, before the decoderrefiner: User validations run on the assembled output, after the decoder (S.reverseswapsinputRefinerβrefiner)to: Target schema for transformations (set byS.shape,S.to)from: Path array indicating where this value comes from in shaped schemasproperties: For object schemas, a dict of field name to schemaitems: For array/tuple schemas, an array of item schemas
A builder is a plain function with signature (input: Val) => Val. The schema being built is available as input.e (expected β there is no separate self-schema parameter). Builders generate JavaScript code at compile time by manipulating val objects:
const myBuilder = (input: Val): Val =>
// `input.e` is this schema; return the output val
B_next(input, `someTransform(${input.v()})`, input.e, input.e);Encoders take an extra target argument (the schema being coerced into): (input: Val, target: Internal) => Val.
A compilation-time representation of a value being processed. Key fields:
inline: The generated code expression (e.g.,i["foo"],v0)var(): Function to allocate/retrieve a variable name (use when value is referenced multiple times)schema: The schema of the current valueexpected: The schema we're trying to parse/convert intoprev: Link to the previous val in the transform chain (walked bymerge)codeFromPrev: Generated statements that produce this val fromprev, including theletdeclaration of its own value. A non-emptycodeFromPrevmakes the val non-hoistable inmerge, so a union discriminant can't be lifted above aletit reads.hoistedDecls:letdeclarations hoisted onto this val by a descendant whose own segment was already emitted (a field read on its parent, a loop accumulator before itsfor). Populated withB.hoistDecl(owner, decl)and emitted bymergeright after this val's checks β no callback mutating an unrelated val.finalized: set bymergeonce a val's code is emitted; a late cached-bond materialization re-reads inline instead of hoisting onto it (#240)checks:array<check>of type-narrows and user refiners. A check whosefail === B.failInvalidTypeis a type-narrow that doubles as a union dispatch discriminant. (Invariant: absent iff no checks β never stored asSome([]).)isOutput:Some(true)once refiners have run; advanced decoders (object/array/tuple/union/recursive) set it themselvesglobal: Shared compilation context containing:embeded: Array of embedded values (functions, constants) accessible ase[n]varCounter: Counter for generating unique variable names
When a schema operation is compiled (e.g., parseOrThrow), parse(val) runs a
loop until the val is fully decoded (isOutput is Some(true) and there is no
further .to). Each iteration:
Input Schema
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β parse(val) loop β one iteration β
β β
β if async flag: β
β - continue the chain inside `.then(...)` β
β β
β else if val.isOutput (decoded, may still have `.to`): β
β - follow `.to`: run `expected.parser` (custom decoder) β
β or `refine` onto `.to` (default encoder coercion) β
β β
β else (not yet decoded): β
β 1. Encoder β if `schema !== expected` and an encoder β
β exists, coerce between schema types β
β 2. Decoder β otherwise narrow to the schema type β
β (e.g. `typeof === "string"`) and push `checks` β
β 3. markOutput β for primitive decoders, apply β
β `inputRefiner`/`refiner` and set `isOutput` β
β (advanced decoders own this themselves) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
Output Val (chain of `.prev` links)
β
βΌ
B.merge(output) β JavaScript code string β wrapped into the operation function
For S.object(s => s.field("foo", S.string)) the generated parse function is:
i => {
typeof i === "object" && i || e[1](i); // object validation
let v0 = i["foo"]; // field access
typeof v0 === "string" || e[0](v0); // string validation
return v0; // return parsed value
};Checks emit as cond || e[n](x); (throw when the condition is false), not as
if (!cond) {...}. Where:
iis the input argumenteis the embedded values array (error throwers, transformers), accessed ase[n]v0,v1, etc. are allocated variables
parse(val): Main compilation loop β encoder β decoder β markOutput β follow.to, until the val is fully decodedB_merge(val, hoistCond?): Walks the.prevchain into a code string. WithhoistCond(union codegen) it lifts type-narrow checks into a dispatch condition; a val with non-emptycodeFromPrevstays non-hoistable so itslettravels with the checkB_next(prev, code, schema, expected): Creates the next val one step down the transform chainB_refine(val, schema?, checks?): Clones a val to attachcheckswhile preserving the var-allocation linkB_hoistDecl(owner, decl): Attaches aletdeclaration to a still-open owner val (prev/parent/self) that dominates and outlives the materialized value, replacing the oldallocateside-channelB_markOutput(val, valInput): AppliesinputRefiner/refinerand marks the val as outputB_embed(val, value): Embeds a runtime value (function, object) and returns a reference likee[0]
Shaped schemas use a proxy-based approach to track how values are used:
- During schema definition, field accesses are tracked via
proxifyShapedSchema - Each accessed field gets
fromset to its path (e.g.,["foo"]fors.field("foo", ...)) - During parsing,
shapedParsertraverses the target structure and maps values from input - During serialization,
shapedSerializerbuilds an accumulator (acc) that maps output paths to input vals, thengetShapedSerializerOutputreconstructs the original structure
Make sure running the below commands in packages/sury-ppx/src.
- Create a sandbox with opam
opam switch create sury-ppx 5.3.0
Or
opam switch set sury-ppx
- Install dependencies
opam install . --deps-only
- Build
dune build --watch
- Test
Make sure running tests
(run compiler for lib)
npm run res
(run compiler for tests)
npm run test:res
(run tests in watch mode)
npm run test -- --watch
For the cross-library comparison table in the README, bundle each library on https://bundlejs.com/ with the recipes below.
sury
export * as S from "sury@11.0.0-rc.1";import * as S from "sury@11.0.0-rc.1";
const schema = S.schema({
number: S.number,
negNumber: S.number,
maxNumber: S.number,
string: S.string,
longString: S.string,
boolean: S.boolean,
deeplyNested: {
foo: S.string,
num: S.number,
bool: S.boolean,
},
});
S.parser(schema)(data);valibot
export * as v from "valibot@1.4.2";import * as v from "valibot@1.4.2";
const schema = v.object({
number: v.number(),
negNumber: v.number(),
maxNumber: v.number(),
string: v.string(),
longString: v.string(),
boolean: v.boolean(),
deeplyNested: v.object({
foo: v.string(),
num: v.number(),
bool: v.boolean(),
}),
});
v.parse(schema, data);zod
export * as z from "zod@4.4.3";import * as z from "zod@4.4.3";
const schema = z.object({
number: z.number(),
negNumber: z.number(),
maxNumber: z.number(),
string: z.string(),
longString: z.string(),
boolean: z.boolean(),
deeplyNested: z.object({
foo: z.string(),
num: z.number(),
bool: z.boolean(),
}),
});
schema.parse(data);export * from "@sinclair/typebox@0.34.52";
// Include Value for transforms support
export * from "@sinclair/typebox@0.34.52/value";
export * from "@sinclair/typebox@0.34.52/compiler";import { Type } from "@sinclair/typebox@0.34.52";
import { TypeCompiler } from "@sinclair/typebox@0.34.52/compiler";
const schema = TypeCompiler.Compile(
Type.Object({
number: Type.Number(),
negNumber: Type.Number(),
maxNumber: Type.Number(),
string: Type.String(),
longString: Type.String(),
boolean: Type.Boolean(),
deeplyNested: Type.Object({
foo: Type.String(),
num: Type.Number(),
bool: Type.Boolean(),
}),
})
);
if (!schema.Check(data)) {
throw new Error(schema.Errors(data).First()?.message);
}ArkType
export * from "arktype@2.2.3";import { type } from "arktype@2.2.3";
const schema = type({
number: "number",
negNumber: "number",
maxNumber: "number",
string: "string",
longString: "string",
boolean: "boolean",
deeplyNested: {
foo: "string",
num: "number",
bool: "boolean",
},
});
schema(data);A running list of strictness or author-guidance features the spec harness
(packages/spec, see the spec skill) could add. When working on Sury you hit a
case the harness should have caught or guided better β a missing check, a weak
error message, a strictness gap that let a bad spec through β add a bullet here
instead of silently working around it.
- An operation whose output holds a
BloborFile(S.blob/S.filedecoding, or the reverse of any conversion into them) can't be specced: the golden writer raises "cannot represent a Blob instance as spec source code", and an op has no way to opt out.Uint8Arrayis written as a constructor call, but a binary container's bytes are only readable asynchronously, so the writer would have to await the example before rendering it. It costs a whole direction of the content axis: thecodec-*specs forS.blobandS.filecarry codegen and error cases only, andtests/content_test.tsholds the values instead. - An example's
erroris matched verbatim, so one raised by the platform rather than by Sury pins that engine's wording:new Blob([Symbol()])says "Cannot convert a Symbol value to a string" on Node 22 and "The argument 'value' is invalid" on Node 24, and the golden passed locally while failing CI. Write such an example so the message is ours β an input whose owntoStringthrows β or the check could compare only the error's constructor when the spec says the failure is the platform's. ts.schemahas to evaluate, so a schema whose construction panics β every argument the public API rejects outright, including the"pack"/"unpack"pairs that don't name two readings β has no spec at all, only acreationErrorfor the ones that survive construction and fail at the operation.tests/content_test.tsholds those. Ats.constructionErrorbesidecreationErrorwould keep them with the schema they reject.operationsnamesparse,decodeandencodeonly, soS.assertInputandS.inputValidatorhave no golden anywhere. Both compile through the same builder chain under a different result target, and a change to that target's handling broke everyS.assertInput(..., S.json)andS.inputValidator(S.jsonString)(...)call with the whole suite green. Anassertop block, even one holding just an expression and a pass/throw example, would have caught it;tests/content_test.tsholds it instead.- A spec for a new export is timed against a baseline that doesn't have it.
The expression evaluates to
undefinedthere,S.parser(undefined)compiles tonoopOperation, and the real validator is then reported as thousands of percent slower than a function that returns its input β PR #420 added 14 formats and got 17 such rows, every one of them bogus. The harness already knows how to saynew:(the same spec'sdecode/encodetargets are listed that way), soparsecould take the same path when the baseline expression isundefined, rather than comparing against a no-op. The accompanyingbehavior changed β baseline accepted it, now rejectedlines have the same cause: a no-op accepts every input, valid or not.
By contributing your code to the rescript-schema GitHub repository, you agree to license your contribution under the MIT license.