a performance-focused Standard Schema library for JS - schema.fuz.dev
fuz_schema is a schema validation library for TypeScript and JS.
It takes Valibot's strategy —
one tree-shakeable function per schema, schemas as plain data, pipe for refinement —
and asks how far that can be pushed on bundle size, speed, and memory
with simpler internals.
import * as s from '@fuzdev/fuz_schema/schema.ts';
const User = s.object({
name: s.pipe(s.string, s.min_length(1)),
age: s.optional(s.number),
role: s.with_default(s.one_of(['user', 'admin']), 'user')
});
type User = s.Infer<typeof User>;
const result = s.parse(User, input);
if (result.ok) result.value;
else result.issues; // [{kind, expected, received, path, message}]On this repo's benchmarks it is smaller than Valibot gzipped on every scenario, faster on every scenario measured, and allocates nothing for a valid value it returns as it came beyond a fixed cost per parse. Those numbers come from one workstation and a small set of scenarios, and Valibot's bytes pay for features this doesn't have. See Measurements for what backs each claim and how to rerun it.
fuz_schema is published as @fuzdev/fuz_schema, has no stable API, and is missing a lot.
AI disclosure: this codebase is LLM-generated, by Claude Code using Claude Opus 5.5, with a human steering the design.
In order:
- correctness — a schema accepts what it says and nothing else
- speed and size together — some bytes are worth paying for a significant speedup, judged by written rules, not taste
- memory — per parse, per schema held, and at startup
- simplicity of the internals
Flexibility comes after all four. Where a feature not everyone needs would cost every user, it's a separate export that the core never reaches: custom messages, message formatting, metadata, serializing, JSON Schema output, the readers of a schema, and the Standard Schema adapter are all like this, and all bundle to nothing unused.
- One shape. Every schema and action is the same
{kind, def, run}object. There's no separate action object,pipeis a loop, and every schema shares one hidden class in V8. - Constants where there are no options.
s.string, nots.string(). - Output is copy-on-write. A valid value that needs no defaults, transforms, or stripped keys comes back as the same object, with nothing allocated for it. A change copies only the path to it.
- Failures are counted, issues are built late. Valid input never builds a path,
a
uniontries its options without allocating, ands.isstops at the first failure. - Issues are data, and hold no input.
{kind, expected, received, path, message}, wherereceivedis the type of the value that failed, not the value. Text is derived byformat_issue, which is only bundled if used. No i18n registry. variantdispatches by map, not by scanning its options.
This is deliberate, for speed, and it's the main thing to know before using it. Output is not a fresh copy.
- Output may alias the input, wholly or in part. Mutating one mutates the other.
- Validation is not a snapshot. A value that can change between reads
(a getter, a proxy, an object someone else holds) can pass and then differ.
Parsed JSON can't do this; objects from untrusted in-process code can.
A revoked
Proxymakesparsethrow aTypeErrorrather than return issues, andistoo on a schema of objects or arrays, seedocs/declined.md. - A class instance that matches comes back as that instance, not a plain object.
What it doesn't give up: strip_object still drops unknown keys, by copying when there are any,
and the input is never mutated.
pipestops at its first failing step.- A failed
unionreports one issue, not its options' issues. It names its options asreceivednames values, each once: aunionamong them by its own options, apipeby its first schema, anullableby its schema's and thennull, anoptionalor a default by its schema's, aliteralor aone_ofby its values, an object schema, arecord, or avariantasobject, and any other schema by its kind:union([nullable(string), one_of(['a', 'b']), pipe(number, check(..))])readsexpected string | null | "a" | "b" | number. - A missing object key is the
undefinedit reads as, except to anunknown, aliteral(undefined), and aone_ofwithundefinedamong its values, which need their key wherever their entry reaches them, as in Valibot and Zod. Valibot and Zod also need the key of a barecheckentry (theircustom), which here accepts a missing key. Seedocs/design.md. numberrefusesNaNand the infinities, as Zod's does, where Valibot's takes the infinities. An infinity reaches a parse from JSON too:JSON.parse('1e999')isInfinity.integertakes an integer past the safe range, as Valibot's does, andsafe_integerdoesn't.pipe(string, uuid)takes a UUID of RFC 9562 with a version from 1 to 8, as Zod's does, and the max UUID in any case, as Valibot's does.pipe(string, iso_datetime)takes2024-02-29T23:59:59.123Zin UTC with a real date, as Zod's does, and a fraction of at most 9 digits, as Valibot'sisoTimestampdoes, and refuses an offset and a space for theT, whichisoTimestamptakes. Seedocs/formats.md.pipe(string, to_lower_case)givestoLowerCase()of the string, as Zod'stoLowerCaseand Valibot's do, and the steps after it are given the lowercase:s.brand(s.pipe(s.string, s.uuid, s.to_lower_case), 'Uuid')takes a UUID in either case and gives it lowercase, as RFC 9562 asks. Abrandorflavorgoes outside a step that changes the value, as there: one on a step before it is dropped, as the output is a new string.with_defaultfills in the value it was given, the same one on every parse, where Zod fills in a copy and Valibot what its schema makes of it, so an array or object default is shared by every output it fills.s.with_default_fn(s.array(s.string), () => [])calls its function for each value it fills in. Neither validates the default, as in Zod.objectrejects unknown keys, where Valibot's and Zod'sobjectdrop them.strip_objectdrops them andloose_objectkeeps them.parsereturns a result and does not throw; the throwing form isparse_or_throw. Code ported from Valibot or Zod that callsparseexpecting a throw will get a result object instead, which TypeScript catches in typed code and nothing catches in untyped code.
Everything here is a named export of schema.ts, used through a namespace import.
Metadata, the Standard Schema adapter, the serializer,
JSON Schema output, and the readers of a schema are modules of their own.
| primitives | string, number, boolean, bigint, unknown, literal, one_of, void |
| wrappers | optional, nullable, with_default, with_default_fn, lazy, message |
| nominal | flavor, brand |
| containers | object, loose_object, strip_object, object_with_rest, record, array, union, memo_union, variant |
| refinement | pipe, check, check_at, transform, to_lower_case, regex, min_length, max_length, min_value, max_value, integer, safe_integer |
| formats | uuid, iso_datetime |
| entry points | parse, parse_or_throw, is |
| issues | format_issue, get_issue_value, SchemaError, fail |
| introspection | is_schema, SchemaNode, SchemaKind, SchemaDef, SchemaDefs |
| types | Infer, InferInput, Schema, SchemaIssue, Flavored, Branded |
Infer<Schema> is unknown and Infer<AnySchema> is any.
A helper generic over this library's schemas and another's reads the output behind a test,
T extends s.Schema ? s.Infer<T> : ...: s.Infer<T & s.Schema> is unknown whatever T is,
as the intersection's run is an overload whose last signature, Schema's, returns unknown.
A pipe takes one to four actions; a pipe of more starts with a pipe, s.pipe(s.pipe(schema, a, b, c, d), e).
The object schemas differ only on keys the schema doesn't name:
object rejects them, loose_object keeps them and is the cheapest when nothing changes,
strip_object drops them and pays for a copy when there are any,
and object_with_rest(entries, rest) validates the value of each own one with rest,
as Zod's catchall and Valibot's objectWithRest do.
None of them accepts an array, and neither do record and variant.
With no entries, object and strip_object output Record<string, never>, not {},
which TypeScript reads as any value but null and undefined.
A key the input lacks reads as undefined, so it fails unless its entry accepts that.
optional, with_default, and with_default_fn do, which is how a key is made optional.
unknown accepts undefined and still needs its key, own or inherited:
{} doesn't match s.object({a: s.unknown}) and {a: undefined} does.
It needs it under nullable, lazy, message, a union, and at the start of a pipe too,
so {} doesn't match s.object({a: s.nullable(s.unknown)}) either.
A union with an option that lets the key be missing makes the key optional.
Where the first option that matches a missing key fills it in, as s.with_default does,
the options before it are tried again on undefined, without the object and the key,
so an unknown among them matches, and the first that matches is the union's.
Write s.optional(s.unknown) for a key that may be missing.
s.literal(undefined) and an s.one_of with undefined among its values need their key the same way.
s.void is s.literal(undefined) typed void, in and out, so a parameter typed from it can be left out:
(input: s.Infer<typeof s.void>) => ... is called as f(). Every reader of a schema takes it as that literal,
and as an entry it needs its key the same way.
A pipe that starts with an optional runs none of its steps for a key that is missing,
and leaves the key out, as in Valibot:
{} matches s.object({a: s.pipe(s.optional(s.string), s.check(...))}) whatever the check says,
and a transform there writes no key. s.with_default is what fills a missing key in.
With the key there, as undefined too, every step runs,
and so do they under a nullable, a lazy, or a union, as steps of the schema that is the entry.
A recursive union whose options walk a node's children before an entry tells them apart
takes time exponential in the input's depth, as each option walks them again:
with two such options the time doubles with each level, see docs/design.md.
On untrusted input of that shape, use variant, or put first in each option the literal entries that tell the options apart, where there are some.
Where there isn't, memo_union is a union that keeps, for one parse, what it gave each object or array it was probed with,
and is linear: a transform, a check, or a with_default_fn's make under it can run fewer times,
and an object the input holds twice gets one output object in both places.
A schema runs its children by calling them, so input nested past the engine's stack makes
parse, parse_or_throw, is, and a Standard Schema's validate throw a RangeError:
on Node's default stack a plain recursive object schema can throw from about 1300 levels,
see docs/design.md, and JSON.parse reads far deeper.
On untrusted JSON that can nest without bound, catch it or bound the depth first.
parse returns {ok: true, value} or {ok: false, issues}.
parse_or_throw returns the value or throws a SchemaError listing the first ten of its issues and how many more it holds.
is answers yes or no without building issues.
// a discriminated union, recursive through `lazy`
interface Tree {
kind: 'leaf' | 'branch';
// ...
}
const Tree: s.Schema<Tree> = s.variant('kind', [
s.object({kind: s.literal('leaf'), value: s.number}),
s.object({kind: s.literal('branch'), children: s.array(s.lazy(() => Tree))})
]);
// custom text, on a schema or on one step of a pipe
const Name = s.pipe(s.string, s.message(s.min_length(1), 'name is required'));
// nominal types: a plain string assigns to an Email but not to a Uuid
const Email = s.flavor(s.pipe(s.string, s.min_length(3)), 'Email');
const Uuid = s.brand(s.string, 'Uuid');
const email: s.Infer<typeof Email> = 'a@b.c'; // ok
const uuid: s.Infer<typeof Uuid> = 'abc'; // error, parse it insteadflavor and brand change only the output type.
A plain value assigns to a flavored type but not to a branded one,
and a branded value comes from parsing or a cast.
null and undefined are left unmarked, so flavor(nullable(string), 'Email') is the flavored string or null.
Marks compose: a brand of a branded schema, s.brand(Path, 'DirPath'), is assignable to Path and not the reverse, and so is a flavor of a flavored one.
Both return the schema itself, so they add nothing to a parse;
the name ships as a string at the call site and is otherwise unused.
An object schema's def is its entries, so a new one is a spread of it into a factory.
There are no extend, pick, omit, or partial helpers, and the spread costs nothing in the library.
const User = s.object({id: s.string, name: s.string, password: s.string});
// extend, and override: a later key wins, in the earlier key's position
const Admin = s.object({...User.def, role: s.literal('admin')});
const Named = s.object({...User.def, name: s.optional(s.string)});
// omit and pick
const {password: _, ...public_entries} = User.def;
const PublicUser = s.object(public_entries);
const UserId = s.object({id: User.def.id});
// another strictness, and a merge
const LooseUser = s.loose_object(User.def);
const Merged = s.object({...User.def, ...Other.def});
// partial: wrap each entry in `optional`, but leave one with a default as it is,
// as `optional` around a default leaves the key out and the default never runs
const Patch = s.object({name: s.optional(Settings.def.name), theme: Settings.def.theme});
// `object_with_rest`'s def is `{entries, rest}`
const More = s.object_with_rest({...Counts.def.entries, label: s.string}, Counts.def.rest);
// a refined base is a `pipe`, whose def isn't entries (a type error):
// extend the bare object, then pipe
const Range = s.pipe(s.object({...Bounds.def, step: s.number}), s.check(in_order));A message, brand, or meta on the base is not carried over.
A generic function that overrides a key types its entries as Omit<T, keyof U> & U,
since a spread of generic entries types as T & U and an overridden key's type collapses.
A failing parse reports its first hundred issues, and builds no issue for the rest.
An issue is {kind, expected, received, path, message}:
kindis the kind of the schema or action that failed, orunknown_keyormissing_keyexpectedsays what was expectedreceivedis the type of the value that failed:'null','array', or itstypeof, but a number that isn't finite is'NaN','Infinity', or'-Infinity'pathis the keys from the root to that value, outermost first. For an unknown or a missing key the last one is the key.messageis the text set bymessage, orundefined
An issue never holds the value that failed.
Issues, and the SchemaError that carries them, can be logged, serialized, and kept
without leaking the input or keeping it alive.
Two things in an issue do come from the input:
pathholds key names as the input gave them — an unknown key, arecordkey — so treat them as untrusted text- a
messagefunction that writes the value into its text has put it there
format_issue writes an issue as path: expected X, received Y, the keys joined with .,
with no path: for an issue at the root, and with the issue's message in place of the
expected X, received Y where it has one.
A key that isn't only word characters is quoted as JSON, so a key can't pass for two keys,
for the rest of the line, or, with a newline in it, for a line of its own:
a.b is two keys and "a.b" is one.
There are two ways to the value:
// a `message` function is handed it
const Port = s.message(s.number, (_issue, value) => `${String(value)} is not a port`);
// and it can be read back out of the input
const result = s.parse(User, input);
if (!result.ok) {
for (const issue of result.issues) {
const found = s.get_issue_value(issue, input);
if (found.ok) found.value;
}
}A message labels every issue of the schema it wraps that has no message yet, its children's included,
and the innermost message wins: s.message(s.object({a: s.message(s.string, 'a')}), 'o')
labels an issue at a with 'a' and any other with 'o'.
A message function's value is what the schema it wraps was given.
Around an action that is the action's input, so the output of the pipe's steps before it.
Around a whole pipe it is the pipe's input, whichever step failed,
and for a child's issue it is still that input, with the issue's path relative to it,
so s.get_issue_value(issue, value) finds the child's.
get_issue_value(issue, input) follows issue.path through input
and returns {ok: true, value} or {ok: false}. It refuses where it would have to guess:
when the path leads through something that isn't an object or array,
and when what it finds isn't of the type in issue.received.
It reads own keys only, so it never returns an inherited value,
and a missing key is found as undefined, which is what the schema saw.
A check of an object that is about one of its fields reports at that field with check_at,
which takes a key or the keys from the value in, as Zod's refine takes a path:
const Signup = s.pipe(
s.object({password: s.string, confirm: s.string}),
s.message(s.check_at('confirm', (v) => v.password === v.confirm), 'passwords must match'),
);
// an issue at ['confirm'], below the keys above the object, received the type of `confirm`The path is typed by the value: a key or each of the keys must be one of what it leads into,
so s.check_at('confrim', ...) fails to compile.
It is a check to every reader, and a pipe stops at the first step that fails,
so of several check_ats, one per field, one reports its issue at a time.
A key an unknown, a literal(undefined), or a one_of with undefined needs and the input lacks is a missing_key, which reads
a: expected a required key, received undefined, except under a union, whose own issue it is.
A missing key whose schema refuses undefined has that schema's issue.
Its limit: it compares types, not values.
When an earlier step of a pipe replaced the value with another of the same type —
a transform, or a container that applied a default or dropped a key —
the issue is about the replacement, and get_issue_value returns the input's value at that path.
A schema is data: kind names what it is and def holds what it was built from.
Not everything given is in def: the expected text of regex and check, the text given to message, and the names given to flavor and brand are not.
SchemaDefs says what def is for every kind, and SchemaNode is the union over it,
so checking kind narrows def:
const unwrap = (schema: s.SchemaNode): s.SchemaNode => {
switch (schema.kind) {
case 'optional':
case 'nullable':
case 'with_default':
return unwrap(schema.def.schema); // all three hold their schema as `def.schema`
default:
return schema;
}
};
unwrap(User.def.age).kind; // 'number'- no options:
defisundefined - one option:
defis that value — aliteral's value, aone_of's array, aregex's pattern, the function given tocheck,transform, orlazy optionalandnullablehold{schema},with_defaultholds{schema, value}, andwith_default_fnholds{schema, make}- the object schemas hold their entries, and
object_with_restholds{entries, rest},arrayandrecordthe schema of their items,unionandpipetheir schemas in order, andvariantholds{key, options}
message, flavor, and brand have no kind: the result has the kind and def of the schema given.
void has none either: it is a literal whose def is undefined.
is_schema(value) checks for the shape — an object with a string kind, a def, and a run function —
and narrows to SchemaNode. It is structural: it doesn't check that the kind is known
or that def matches it. It is also the way in for a schema typed as a bare Schema,
as a recursive one is.
The narrowed type is for reading a schema as data: a schema typed with its kind keeps its own type,
while one typed as a bare Schema or AnySchema (as recursive schemas usually are) reads as SchemaNode,
whose output type is unknown.
introspect.ts has the walks a consumer would otherwise write:
unwrap_schema goes through optional, nullable, with_default, with_default_fn, pipe, and lazy;
get_schema_meta finds a key on the outermost layer that has it;
find_schema_meta finds every value of a key on any schema a value can reach:
every step of a pipe, every option of a union, and, with {structural: true},
object entries and what an array or record holds, reading each schema once so a recursion through one schema object ends;
get_schema_entries and get_schema_fields read an object schema's entries under its wrappers,
with each one's default and description, and never call a with_default_fn's function;
format_schema_type renders the type a schema accepts, for help output.
import {meta} from '@fuzdev/fuz_schema/meta.ts';
import {get_schema_fields, format_schema_type} from '@fuzdev/fuz_schema/introspect.ts';
const Args = s.object({port: meta(s.with_default(s.number, 3000), {description: 'the port'})});
get_schema_fields(Args);
// [{name: 'port', schema, has_default: true, default_computed: false, default: 3000, description: 'the port'}]
format_schema_type(Args); // '{port?: number}'meta attaches metadata to a schema and get_meta reads it back.
They are a module of their own, backed by a WeakMap, so a schema stays {kind, def, run}
and a bundle that attaches no metadata carries none of it.
import {meta, get_meta} from '@fuzdev/fuz_schema/meta.ts';
const Port = meta(s.number, {description: 'the port to listen on'});
get_meta(Port); // {description: 'the port to listen on'}
get_meta(s.number); // undefinedmeta returns a copy of the schema with the same kind, def, and run, and leaves the one given alone:
s.number is a constant every use shares.
The copy has the schema's type and parses as it does, so it goes anywhere a schema goes,
and as an object entry meta(s.optional(...), ...) still makes the key optional.
- Calling
metaon a schema that has metadata merges the two, the new keys winning. get_metareads the object it is given and nothing inside it. Fors.optional(Port)that isundefined, andget_meta(schema.def.schema)finds it: metadata on a nested schema is reached by walking.get_schema_metawalks the layers, andfind_schema_metaevery schema a value can reach.flavorandbrandreturn the schema itself, so its metadata is still there.messagebuilds a new schema and carries the metadata over, the same object, sometagoes inside or outside it alike, and one outside merges as on any schema.- Every copy of the module shares one
WeakMap, onglobalThis, so metadata attached through one is read through any other: a second install, a bundler's duplicate, or another version that keeps it there.
description is built in. Other keys are added to the SchemaMeta interface by declaration merging,
from a module, naming the module as it is imported:
declare module '@fuzdev/fuz_schema/meta.ts' {
interface SchemaMeta {
aliases?: Array<string>;
}
}The package exports every module under both a .ts and a .js path.
Either names the same declarations, so an augmentation through one is seen through both.
A schema is not itself a Standard Schema; wrap it where a library asks for one:
import {to_standard_schema} from '@fuzdev/fuz_schema/standard_schema.ts';
const standard = to_standard_schema(User); // {'~standard': {version: 1, vendor, validate}}The types follow version 1.1 of the spec and are copied from it, so there's no dependency on it.
validate is synchronous and ignores the spec's options argument,
and the input and output types are carried by the result's type, for the spec's own inference to read.
It's an adapter so that schemas don't each carry a ~standard property.
Schemas that are Standard Schemas natively were measured and declined, as a flat cost
to every bundle: see docs/declined.md.
to_schema_json writes a schema as plain JSON: every node as {kind, def},
in an envelope whose defs hold what each lazy names.
It is the form the Rust side loads, and a module of its own.
The Rust crate isn't published; the form is plain JSON and stands without it.
import {to_schema_json} from '@fuzdev/fuz_schema/schema_json.ts';
to_schema_json(s.array(s.optional(s.number)));
// {schema: {kind: 'array', def: {kind: 'optional', def: {schema: {kind: 'number'}}}}}It throws on a schema the form can't hold, saying where:
regex, check, transform, and with_default_fn, which hold JS values
(uuid and iso_datetime are written, in place of a regex for a UUID or a date and time in UTC, and so is to_lower_case);
object_with_rest, which the crate doesn't carry yet;
a bigint or undefined literal; a default that isn't a JSON value;
an entry named like a member of Object.prototype.
The text of message and the names of flavor and brand are not in a schema's data, and are not written.
docs/rust.md has the form and everything refused.
to_json_schema writes a schema as JSON Schema draft 2020-12, in the shapes Zod's toJSONSchema writes,
a module of its own. io picks the side of a parse it describes, 'output' by default or 'input',
which differ on defaults, strip_object, and transform.
import {to_json_schema} from '@fuzdev/fuz_schema/json_schema.ts';
to_json_schema(s.object({name: s.string, port: s.optional(s.number)}));
// {$schema: 'https://json-schema.org/draft/2020-12/schema', type: 'object',
// properties: {name: {type: 'string'}, port: {type: 'number'}},
// required: ['name'], additionalProperties: false}It throws on what JSON Schema can't say, saying where: a bigint, a check, a transform on the output side,
a regex with flags other than d and u, a literal of undefined.
With unrepresentable: 'any' it writes {} there instead, or leaves out the keyword of a pipe's step.
to_lower_case writes nothing, as Zod writes nothing for its toLowerCase,
and a step that reads another string than the document's is refused, as Zod's is not:
after it on the input side, which takes the input and not its lowercase, and before it on the output side.
A uuid holds of both cases, so s.brand(s.pipe(s.string, s.uuid, s.to_lower_case), 'Uuid') is written as a uuid.
Metadata from meta is copied in. docs/design.md has the mapping and where it differs from Zod's.
status: early — the API is unstable and incomplete
- the schemas and actions in the table above, with output and input type inference
- custom messages, as an opt-in wrapper
- Standard Schema v1, as an opt-in adapter
- metadata, as an opt-in module
- serializing a schema to JSON, as an opt-in module
- JSON Schema output, as an opt-in module
- copy-on-write output
-
flavorandbrand, type-level only - nested issues for a failed
union -
tuple,intersect - extend, merge, pick, omit, and partial, as a spread of
def, see Composing object schemas - abort-early as a caller option
- a published package
- the Rust side: the schema model, whether a JSON text matches, its issues, and a wasm build to measure are built, output is not
Not designed yet: async validation, i18n.
What copy-on-write costs: the copy is made after the fact, so the keys it keeps are read a second time, and finding a key to drop means enumerating the input's keys.
Against a version that always copies, re-measured roughly on the current loops and not by the grader (the earlier graded study is Always a copy), strip_object dropping a key takes about 1.2 to 1.6x as long as there, and a default applied late in a wide object about 1.2 to 1.3x.
In exchange, an input that comes back as it is parses about 1.3 to 1.4x faster at six keys, and nearly three times at twenty.
The ways tried to recover the rest were declined, see docs/declined.md and the copy filled from the start.
The measurements below come from the benchmarks in src/benchmarks/,
run against Valibot, Zod, Zod Mini, TypeBox, and ArkType.
npm run benchmark # parse time, every library on every scenario
npm run benchmark:bundle # bundle size per scenario and per export, minified and gzipped
npm run benchmark:allocations # heap bytes per parse and per schema built
npm run benchmark:coldstart # a fresh process: import, build, first parse, peak memory
npm run benchmark:heap # memory still held after building and parsing
npm run benchmark:types # what each library costs the TypeScript checker
npm run benchmark:survey # many small shapes against Valibot and Zod, by what a parse doesWhat they've shown so far, read with the caveats below:
- Size: about 0.84x Valibot gzipped with the scenarios bundled together, and far below the rest. The gap is about 0.6 kB with every scenario together and about 0.4 kB on a lone string schema, so most of it is fixed cost, which is small in absolute terms.
- Warm speed: faster than Valibot on every scenario, by about 2x on a flat object, 3x on a list of records, and more on recursive and union-heavy ones, and by about a tenth where an object's unknown keys are dropped. Not the fastest: Zod's generated object parser wins on flat objects, slightly, and by about 2.5x where keys are dropped, and TypeBox and ArkType, which compile validators, win flat shapes by a wide margin.
- Allocation: a valid parse that returns its input allocates a small fixed amount whatever the size of the value,
the same for a hundred records as for one, unless a step makes a new value, as
to_lower_casedoes. One that drops keys allocates its copy as well. - Cold start: under a millisecond to import, build a schema, and parse once, bundled. The libraries that compile validators pay here, by one to two orders of magnitude. The first hundred parses of a fresh process, before the engine has optimized them, are several times faster than Valibot's on a recursive schema.
- Retained memory: the lowest of the six.
- Type-checking cost: in instantiations, below Valibot, Zod, and ArkType, and above Zod Mini and TypeBox.
Caveats:
- The scenarios are few and small. One is a real recursive schema taken from svelte-docinfo; the rest are synthetic.
- Timings come from one desktop (a Ryzen 7 5800X) on Node 26, quieted but not isolated. Bundle sizes are exact, and allocation is exact for the code the engine is running when it is sampled.
- It's V8 only. The one-shape design is tuned to V8's hidden classes.
- It isn't like-for-like. Valibot does more, and TypeBox's check doesn't build an output.
- A test holds every library to the same answer on every scenario's valid and invalid input, so the benchmarks at least compare the same work.
npm run grade # the working tree against HEAD
npm run grade -- --against HEAD~2 --filter real/grade compares two versions of the library over a matrix of cases
(realistic shapes, one case per code path, and the same shapes at scale):
time, heap bytes allocated, and whether the output is the input itself.
Each version is loaded several times over, in fresh processes, several runs of them,
so every time is printed beside what copies of one version differed by, its A/A.
A few cases are also timed with each version in processes of its own,
where a cost one version puts on everything in a process shows.
It then compares bundle size, retained heap, and cold start,
and checks the result against the rules a change is accepted by:
- speed is judged on the realistic cases, valid input first
- a time difference is read against its A/A, and inside it is noise
- bytes that tree-shake away unless imported only have to pay for themselves
- bytes every user pays need a return: roughly 10% on the realistic cases per 50 gzipped bytes
- a fix of correctness or conformance has a price instead: 4 gzipped bytes every user pays, 50 on any one export or scenario
- every scenario of one schema stays at or under 0.75 of Valibot's gzipped size for the same scenario, or at a size the user pinned it at
- a fix of security (an input that should fail and doesn't, denial of service on data-shaped input, input leaking into issues) comes first, at the correctness price, and past that 0.75 the user rules
- the valid path is never slowed to speed up the invalid one
- anything that generates code at runtime is opt-in, because of CSP
- what each further schema holds may grow by 16 bytes, of a scenario or of one export alone
docs/grading.md has the rules in full,
and docs/benchmarks.md the method behind every number.
npm i
gro check # typecheck, test, gen check, format check, lint
gro testThe repo is also a Rust workspace. crates/fuz_schema is the Rust side of the
library: the same schema model, loaded from a schema serialized in TypeScript,
whether a JSON text matches it, and the issues of one that doesn't, tested
against this library on shared fixtures. crates/fuz_schema_wasm compiles it
to wasm, to measure against this library. Neither crate is published; the npm
package is the TypeScript library. Run them with cargo, and the measurement
with npm:
cargo test --workspace
npm run build:wasm # needs wasm-pack
npm run benchmark:wasmdocs/design.md— the internals and why: the one shape, probe mode, copy-on-write and what it gives up, issues and messages, metadata, JSON Schemadocs/benchmarks.md— what each benchmark measures and how, how far to trust each number, and how to extend themdocs/testing.md— what holds the library to more than its own examples: conformance, the differential, the JSON fixtures, and mutantsdocs/grading.md—npm run gradeand the rules a change is accepted bydocs/declined.md— trades that were measured and not taken, each with what it gave, what it cost, and what would reopen itdocs/formats.md— the grammars ofuuidandiso_datetime, and what decides a grammar where Zod and Valibot disagreedocs/rust.md— the Rust side: the model, the interchange form, the two evaluators, the issues, where they differ from this library, what is refused, and the wasm buildCLAUDE.md— layout, commands, and invariants (also the context file for AI agents)- fuz-stack skill — coding conventions shared across the Fuz stack
Valibot for the strategy this builds on and Zod before it. The process-per-sample cold-start and retained-heap methods follow syntax-highlighter-bench, which generalizes Twinkleplop's cold-start harness.
Valibot's source was not consulted while writing fuz_schema; the comparison libraries are used only through their public APIs, as benchmark baselines.