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}]

It's an experiment, and "use Valibot" is a valid outcome. The question is whether the gap is worth owning a validation library for. So far, on this repo's benchmarks, it is about 0.7x Valibot's size gzipped with every scenario bundled together and less on each alone, 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 shared 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, 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 type, 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 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 expects the kinds of its options, each once, a union among them by its own options and a pipe by its first schema: union([string, union([number, pipe(string, check(..))])]) reads expected string | 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. They also need the key of the one other entry that accepts undefined, a check with no schema before it, and here that accepts a missing key. See docs/design.md.
  • number refuses NaN and the infinities, as Zod's does, where Valibot's takes the infinities: a JSON text carries one as a number too large for a double, which JSON.parse reads 1e999 as. integer takes an integer past the safe range, as Valibot's does, and safe_integer doesn't.
  • 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, and the readers of a schema are modules of their own.

primitives string, number, boolean, bigint, unknown, literal, one_of
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, variant
refinement pipe, check, transform, regex, min_length, max_length, min_value, max_value, integer, safe_integer
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

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.

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 take the missing key as undefined, an unknown among them, 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.

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.

parse returns {ok: true, value} or {ok: false, issues}. parse_or_throw returns the value or throws a SchemaError listing every issue. 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. 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.

Issues

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

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

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; 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.
  • flavor and brand return the schema itself, so its metadata is still there. message builds a new schema, so attach metadata last: meta(s.message(...), ...).
  • 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 JavaScript values; 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.

Status

status: early experiment — unstable, 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
  • copy-on-write output
  • flavor and brand, type-level only
  • nested issues for a failed union
  • tuple, partial, pick, omit, intersect
  • 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: JSON Schema output, async validation, i18n.

Known regressions to fix: strip_object got slower when it has unknown keys to drop, and when a default applies late in a wide object. Both came with copy-on-write and share a cause: the copy is made after the fact, so the keys it keeps are read a second time. Dropping keys also has to enumerate the input's keys first.

Measurements

Every claim above comes from a benchmark 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.7x Valibot gzipped with the scenarios bundled together, and far below the rest. The gap is about 0.8 kB with every scenario together and nearly 0.5 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, by 3x 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. 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.
  • Retained memory and type-checking cost: the lowest or near it of the six.

Caveats:

  • The scenarios are few and small. One is a real recursive schema taken from svelte-docinfo; the rest are synthetic.
  • Timings come from a shared workstation, not a quiet machine. 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 a realistic case 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 stays under 0.75 of Valibot's gzipped size for the same scenario
  • 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, 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
  • docs/benchmarks.md — what each benchmark measures and how, how far to trust each number, and what isn't measured
  • 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/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)

Credits

Valibot for the strategy this builds on. The process-per-sample cold-start and retained-heap methods follow syntax-highlighter-bench, which generalizes Twinkleplop's cold-start harness.

fuz_schema was written with Claude Code using Claude Opus 5.5: the library, tests, benchmarks, and docs. Valibot's source was not consulted while writing it; 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