Skip to content

About

performance-focused Standard Schema library for JS

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

fuz_schema

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.

Priorities

In order:

  1. correctness — a schema accepts what it says and nothing else
  2. speed and size together — some bytes are worth paying for a significant speedup, judged by written rules, not taste
  3. memory — per parse, per schema held, and at startup
  4. 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.

What's different from Valibot

  • One shape. Every schema and action is the same {kind, def, run} object. There's no separate action object, pipe is a loop, and every schema shares one hidden class in V8.
  • Constants where there are no options. s.string, not s.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 union tries its options without allocating, and s.is stops at the first failure.
  • Issues are data, and hold no input. {kind, expected, received, path, message}, where received is the type of the value that failed, not the value. Text is derived by format_issue, which is only bundled if used. No i18n registry.
  • variant dispatches by map, not by scanning its options.

What copy-on-write gives up

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 Proxy makes parse throw a TypeError rather than return issues, and is too on a schema of objects or arrays, see docs/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.

Other differences in behavior

  • pipe stops at its first failing step.
  • A failed union reports one issue, not its options' issues. It names its options as received names values, each once: a union among them by its own options, a pipe by its first schema, a nullable by its schema's and then null, an optional or a default by its schema's, a literal or a one_of by its values, an object schema, a record, or a variant as object, and any other schema by its kind: union([nullable(string), one_of(['a', 'b']), pipe(number, check(..))]) reads expected string | null | "a" | "b" | number.
  • A missing object key is the undefined it reads as, except to an unknown, a literal(undefined), and a one_of with undefined among 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 bare check entry (their custom), which here accepts a missing key. See docs/design.md.
  • number refuses NaN and the infinities, as Zod's does, where Valibot's takes the infinities. An infinity reaches a parse from JSON too: JSON.parse('1e999') is Infinity. integer takes an integer past the safe range, as Valibot's does, and safe_integer doesn'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) takes 2024-02-29T23:59:59.123Z in UTC with a real date, as Zod's does, and a fraction of at most 9 digits, as Valibot's isoTimestamp does, and refuses an offset and a space for the T, which isoTimestamp takes. See docs/formats.md.
  • pipe(string, to_lower_case) gives toLowerCase() of the string, as Zod's toLowerCase and 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. A brand or flavor goes 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_default fills 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.
  • object rejects unknown keys, where Valibot's and Zod's object drop them. strip_object drops them and loose_object keeps them.
  • parse returns a result and does not throw; the throwing form is parse_or_throw. Code ported from Valibot or Zod that calls parse expecting a throw will get a result object instead, which TypeScript catches in typed code and nothing catches in untyped code.

API

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 instead

flavor 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.

Composing object schemas

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.

Issues

A failing parse reports its first hundred issues, and builds no issue for the rest. An issue is {kind, expected, received, path, message}:

  • kind is the kind of the schema or action that failed, or unknown_key or missing_key
  • expected says what was expected
  • received is the type of the value that failed: 'null', 'array', or its typeof, but a number that isn't finite is 'NaN', 'Infinity', or '-Infinity'
  • path is the keys from the root to that value, outermost first. For an unknown or a missing key the last one is the key.
  • message is the text set by message, or undefined

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:

  • path holds key names as the input gave them — an unknown key, a record key — so treat them as untrusted text
  • a message function 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.

Introspection

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: def is undefined
  • one option: def is that value — a literal's value, a one_of's array, a regex's pattern, the function given to check, transform, or lazy
  • optional and nullable hold {schema}, with_default holds {schema, value}, and with_default_fn holds {schema, make}
  • the object schemas hold their entries, and object_with_rest holds {entries, rest}, array and record the schema of their items, union and pipe their schemas in order, and variant holds {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}'

Metadata

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); // undefined

meta 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 meta on a schema that has metadata merges the two, the new keys winning.
  • get_meta reads the object it is given and nothing inside it. For s.optional(Port) that is undefined, and get_meta(schema.def.schema) finds it: metadata on a nested schema is reached by walking. get_schema_meta walks the layers, and find_schema_meta every schema a value can reach.
  • flavor and brand return the schema itself, so its metadata is still there. message builds a new schema and carries the metadata over, the same object, so meta goes inside or outside it alike, and one outside merges as on any schema.
  • Every copy of the module shares one WeakMap, on globalThis, 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.

Standard Schema

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.

Serializing

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.

JSON Schema

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

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
  • flavor and brand, 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.

Measurements

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 does

What 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_case does. 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.

Grading a change

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.

Develop

npm i
gro check  # typecheck, test, gen check, format check, lint
gro test

The 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:wasm

Docs

  • docs/design.md — the internals and why: the one shape, probe mode, copy-on-write and what it gives up, issues and messages, metadata, JSON Schema
  • docs/benchmarks.md — what each benchmark measures and how, how far to trust each number, and how to extend them
  • docs/testing.md — what holds the library to more than its own examples: conformance, the differential, the JSON fixtures, and mutants
  • docs/grading.md — npm run grade and the rules a change is accepted by
  • docs/declined.md — trades that were measured and not taken, each with what it gave, what it cost, and what would reopen it
  • docs/formats.md — the grammars of uuid and iso_datetime, and what decides a grammar where Zod and Valibot disagree
  • docs/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 build
  • CLAUDE.md — layout, commands, and invariants (also the context file for AI agents)
  • fuz-stack skill — coding conventions shared across the Fuz stack

Credits

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.

Contributing

fuz.dev/contributing

License 🐦

0BSD

About

performance-focused Standard Schema library for JS

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Used by

Contributors

Languages

Generated from fuzdev/fuz_template