Performance review#
Where the time goes when shape validates, in both implementations, and what
would make it faster. The recommendations that follow were carried out;
the figures here are the ones the review was written from, and the
benchmark report carries the
current numbers. The review predates the Rust port, which the benchmarks
and the report now measure too but which was not profiled here. The
TypeScript figures here, and every TypeScript run recorded before
2026-09-03, measured a benchmark whose String, Number and Boolean
leaves were undefined, which shape reads as an optional any: a slower
shape than the Go and Rust benchmarks measure, so the TypeScript runs
after the correction are not comparable with the ones before it. Measured with the benchmarks
in bench/ and CPU and allocation profiles of the flat, nested and
array cases; the numbers are from one Linux host and will differ elsewhere,
but the proportions are what matter. The live comparison with other
validators is on the performance report.
Where shape stands#
| case (median per validation) | shape TS | Zod | Ajv | shape Go | validator | jsonschema |
|---|---|---|---|---|---|---|
flat (5 properties) |
3.5 µs | 155 ns | 44 ns | 3.1 µs | 460 ns | 2.6 µs |
nested |
7.0 µs | 436 ns | 101 ns | 7.1 µs | 1.5 µs | 6.0 µs |
array (50 objects) |
114 µs | 7.1 µs | 1.4 µs | 123 µs | 20 µs | 92 µs |
Shape is roughly level with Joi and with the Go JSON Schema validators, and an order of magnitude behind the fast TypeScript validators. That gap is not in the checks themselves, which are cheap; it is in the work the walk does around them. The profiles say the same thing in both languages: most of a validation is spent preparing to validate, not validating.
TypeScript#
The CPU profile of valid() on the three cases, by self time:
| share | where | what it does |
|---|---|---|
| 40 % | exec, the object branch |
per property, per validation: a regular-expression match of the key against the key-expression grammar, nodize of the child spec, a Set of the known keys, keys() of the spec and of the value, an extra array |
| 27 % | State.next |
dereferencing the back-pointer stacks (nodes, vals, parents, keys, path) and an Object.isFrozen check per node |
| 20 % | valid (inlined exec prologue) |
building a State, the ctx.skip sets, the produced-value copy |
| 3 % | the key-expression regexp | `^\s*("(\. |
The root cause is that the spec is compiled lazily, inside the walk. When
exec reaches an object node it re-derives the node's children from n.v:
it parses "name: Min(1)" keys, nodizes each child, records the known keys
in a fresh Set, and writes the results back into the node. The write-back
means the second validation finds nodes rather than raw specs, but the loop,
the regexp, the Set and the keys() calls run every time. A closed object
also builds an extra array of the value's keys not in the spec, whether or
not there are any.
Second, every call pays the same setup. valid(), match(), error()
and the producing call cost the same within a few percent (3.5–3.7 µs on
flat): the boolean calls run the producing walk on the input itself
(valid injects defaults into it, by design), and match mode saves only the
writes. What the profile attributes to valid's inlined prologue, about
20%, is the per-call construction of the State with its five stacks, the
ctx.skip sets built whether or not ctx.skip is present, and the error
array plumbing.
Third, the walk carries more state per node than it reads: five parallel
stacks indexed by the same counters, a path array rewritten at every depth,
and ancestors. Each next() touches all of them.
Recommendations, in order of expected effect#
Compile the spec once, in
Shape(). Nodize the whole tree, resolve key expressions, and precompute for each object node the ordered child list, the known-key set (or a plain object used as a set) and whether it is closed; for each array node the fixed slots and the child.execthen walks a finished tree and never touchesKEY_EXPR_RE,nodize,keys()of the spec, ornew Set. This removes most of the 40 % in the object branch and the regexp entirely. It is also the change that unlocks the rest, because a compiled node can carry whatever the walk needs. Expected: 2–3× onflatandnested.Trim the per-call setup. Allocate the walk's frames lazily and reuse the arrays across calls, build the
ctx.skipsets only whenctx.skipis given, and keep the error plumbing off the path until an error is recorded.valid()keeps its contract of injecting defaults into the input. Expected: about 1.2× on every call.Skip the extra-key scan for open objects. A closed object must still collect every unknown key, since the message names them all, but with a compiled known-key lookup that is one pass; an open object without a child shape needs no scan at all.
Collapse the parallel stacks into one array of frames (node, value, parent, key) pushed and popped together, with
state.pathbuilt from the frames on demand, sinceKeyand custom validators read it on successful walks. Keep the frozen-parent handling on the producing path (a frozen nested object must still be copied before its children are written) but check it once per object parent rather than at every node.Specialise the leaf kinds. A required
String,NumberorBooleanwith no builders is the common case; a compiled node can carry afastflag so the walk performs the leaf's whole check (typeof, and the non-empty, non-NaNand integer rules the corpus pins) without entering the generic before/after loop.
Together these are the difference between the current numbers and the
Zod/Valibot tier; Ajv's code generation is a further step (compile the tree
to a function with new Function) that is only worth taking after the walk
itself is lean.
Go#
The allocation profile of Valid() on flat and nested:
| case | time | bytes | allocations |
|---|---|---|---|
flat |
3.5 µs | 1.9 kB | 28 |
nested |
9.0 µs | 4.8 kB | 68 |
35 % of CPU is mallocgc and the map runtime, and the allocation sites are
plain:
| share of bytes | where | what |
|---|---|---|
| 36 % | validateObject |
out := map[string]any{} and out[k] = v for every key: every object in the input is copied, in match mode too |
| 12 % | validateObject |
kpath := append(path, k) and kpathArr := append(pathArr, k): two new path slices per property |
| 4 % | validateObject |
unknown := make([]string, 0, len(obj)) per object, used only when a key is unknown |
| 7 % | newContext |
a fresh Context with its Refs map per call |
| 53 % | validateNode |
boxing of results into any and the per-node State for validators |
And collectDefines walks the entire node tree on every Validate,
Match and Error call to register Define names, which is 4 % of CPU on a
tree with no definitions at all.
Recommendations, in order of expected effect#
Do not copy in match mode.
ValidandMatchneed a verdict; walk the input map in place and allocate nothing for the result. This removes the largest allocation site and most of the map-assignment time on the boolean path. Expected: 1.5–2× onValid.Collect definitions at compile time. Walk the tree once in
Shape()and keep theRefsmap on theSchema;newContextthen copies a reference rather than rebuilding it, and the 4 % walk disappears.Build paths lazily. Pass the parent and key down and materialise the
[]stringpath from that chain when a validator or an error asks for it, which keepsState.Pathcorrect for custom validators andKeywhile saving two allocations per property on every input that never asks.Allocate
unknownonly on the first unknown key, and skip the scan for open objects without a child shape, as in TypeScript.Reuse the
Contextand scratch state ofMatchandValidacross calls with async.Pool, or letValidkeep a small stack-allocated error counter instead of an issues slice. The*ValidationErrorthatValidatereturns and theIssuesthatErrorreturns escape to the caller and must never be pooled.Pre-size
out(make(map[string]any, len(obj)+defaults)) on the producing path, where the copy is genuinely needed, so the map does not grow in steps.
Each of these was carried out. The benchmark report has the current numbers for every case on every host measured.
Keeping parity while doing this#
Every item here changes how the walk is done, not what it accepts, produces
or says, so the shared corpus and the
differential harness are the safety
net: the exact error text, the produced value, and the JSON Schema export
must not move. Do the TypeScript change first, prove it with make test and
make diff, then mirror it in Go, as for any change. Record a benchmark run
before and after (make bench) so the report shows the effect per host.