Skip to content

Compilation and Caching

Converting a JSON Schema to a wire plan takes 52–66 µs in the cold-start benchmark, including Zod schema construction. With a stable schema object, this happens once per schema rather than once per call.

const bytes = encode(Person, person);
const back = decode(Person, bytes);

The plan is stored in a WeakMap keyed on the schema object’s identity, so a schema going out of scope takes its plan with it.

Measured: the cached functional API reached 3.53M encodes/s, compared with 3.49M for compiled.encode. The difference is measurement noise, so use whichever API is clearer.

const PersonWire = compile(Person);

Use it for a codec object to pass around, store in a map, or wrap in fingerprinted(). codec and fromStandard are aliases.

There is still no build step. compile builds a tree of Schema objects in memory and writes nothing to disk. It does, however, generate one function at runtime — see below.

An object schema with no optional fields builds its record decoder with new Function when the codec is constructed, giving that schema a decode function of its own rather than a shared interpreted loop. Encoding is unaffected and still interpreted, as is any object that has an optional field.

The generated function is not an optimisation of the loop so much as an escape from a V8 detail: feedback vectors are allocated per closure creation site, so one shared helper collects the hidden classes of every object schema in the program and goes megamorphic. Measured, a shared unrolled helper is 2.7× faster than the loop with one schema loaded and 3× slower once a dozen schemas share its call sites. Only a distinct function per schema keeps those sites monomorphic.

Schema keys are never parsed as code. They are passed to the generated function as arguments and used as computed properties, so a key taken from a fetched JSON Schema cannot become executable source. This costs about 5% against interpolating them as string literals.

A policy without unsafe-eval makes new Function throw. shorn catches that and uses the interpreted decoder instead — no error, no configuration, identical bytes and identical decoded values, only slower. test/core.test.ts runs the whole path with new Function stubbed out to a throw and cross-decodes both directions to prove the two agree on the wire.

// Cached: one plan, reused.
const Person = z.object({ name: z.string() });
export const write = (p) => encode(Person, p);
// Not cached: a new schema per call, so a new plan per call.
export const write = (p) => encode(z.object({ name: z.string() }), p);

The second form pays the full conversion on every call. Hoist schemas to module scope.

The cache is keyed on the schema and the structure object:

// Cached.
const structure = toStandardJsonSchema(Person);
export const write = (p) => encode(Person, p, { structure });
// Not cached: toStandardJsonSchema returns a fresh object each call — and is
// not free itself, so this form pays twice.
export const write = (p) => encode(Person, p, { structure: toStandardJsonSchema(Person) });

encode reuses one module-level Writer and resets it after every call, including when encoding throws. This has two effects:

  • encode returns an exact-size copy, not a view into an oversized buffer. Retained encode memory is 4.11 MiB for a 4.04 MiB payload, down from 12.10 MiB.
  • Buffers grown past 64 KiB are released, so one large encode does not permanently inflate the process.

Cold setup is usually negligible in a long-lived server, but it can matter in a serverless function that handles only one request. For comparison: Avro takes 68.99 µs, Protobuf.js reflection 187.75 µs, SchemaPack 3.00 µs, msgpackr records 1.00 µs, and JSON 0.08 µs. Most of shorn’s time is Zod schema construction, which an application using Zod already pays. See Footprint.