4. Doc Codegen & JIT Schema Compiler (compile.ts)
Zod v4 achieves orders-of-magnitude validation throughput improvements over Zod v3 through a two-tier dynamic code generation architecture powered by a lightweight JavaScript source builder (Doc in packages/zod/src/v4/core/doc.ts). At the first tier, $ZodObjectJIT automatically compiles flat object property loops on first synchronous parse. At the second tier, the Ahead-of-Time (AOT) compiler (packages/zod/src/v4/core/compile.ts) compiles entire schema ASTs into zero-allocation validation closures while coordinating with the cycle-detection engine (packages/zod/src/v4/core/memoizer.ts).
Source Code
from drawlib.canvas import save, setup
from drawlib.icons import phosphor
from drawlib.smartarts import CardList, ChevronProcess, Panel
from drawlib.styles import Styles
setup(width=164, height=84)
# Top Pipeline: 4-Stage Compilation Flow
pipeline = ChevronProcess(
style=Styles.Neutral,
text_style=Styles.DarkBold.patch(text_size=11.0),
description_style=Styles.Dark.patch(text_size=10.0),
corner_angle=55.0,
spacing=1.5,
flat_left_end=True,
)
pipeline.add("1. Cycle & CSP Gate", description="isRecursiveSchema()")
pipeline.add("2. AST Walk & Doc", description="generateCheck(doc, ctx)", style=Styles.PrimaryNeutral)
pipeline.add(
"3. Closure Hoist",
description="new Function(c0..cN)",
style=Styles.PrimaryFlat,
text_style=Styles.WhiteBold.patch(text_size=11.0),
description_style=Styles.White.patch(text_size=10.0),
)
pipeline.add("4. Wrapper Install", description="withParser(schema)")
pipeline.draw(xy=(6, 65), width=152, height=14)
# Bottom Left Panel: Two-Tier Codegen Architecture
left_panel = Panel(
"Two-Tier Codegen",
"doc.ts & compile.ts",
style=Styles.Neutral,
header_style=Styles.PrimaryNeutral.patch(shape_line_width=1.2),
text_style=Styles.DarkBold.patch(text_size=11.0),
description_style=Styles.Dark.patch(text_size=10.0),
icon=phosphor.cpu,
icon_style=Styles.PrimaryBold,
header_variant="filled",
header_height=8.5,
)
left_panel.draw((6.0, 5.0), width=73.0, height=56.0, align="bottom_left")
l_xy, l_w, l_h = left_panel.get_content_box((6.0, 5.0), width=73.0, height=56.0, padding=(3.0, 2.5))
tier_cards = CardList(
style=Styles.White,
text_style=Styles.DarkBold.patch(text_size=10.5),
description_style=Styles.Dark.patch(text_size=10.0),
badge_shape="rounded_rect",
badge_style=Styles.PrimaryNeutral,
badge_text_style=Styles.PrimaryBold.patch(text_size=10.0),
spacing=2.2,
)
tier_cards.add(
"$ZodObjectJIT (schemas.ts)",
"Closes over shape, inst, memo & syms",
badge_text="T1",
)
tier_cards.add(
"z.compile() AOT (compile.ts)",
"Inlines AST; returns value or INVALID",
badge_text="T2",
badge_style=Styles.PrimaryFlat,
badge_text_style=Styles.WhiteBold.patch(text_size=10.0),
)
tier_cards.add(
"Assert-Only Validator Split",
"assertOnly: true skips output build",
badge_text="V8",
)
tier_cards.draw(l_xy, width=l_w, height=l_h)
# Bottom Right Panel: Fallback & Degradation Boundaries
right_panel = Panel(
"Fallback Boundaries",
"Islands & Replay",
style=Styles.Neutral,
header_style=Styles.SecondaryNeutral.patch(shape_line_width=1.2),
text_style=Styles.DarkBold.patch(text_size=11.0),
description_style=Styles.Dark.patch(text_size=10.0),
icon=phosphor.shield_check,
icon_style=Styles.DarkBold,
header_variant="filled",
header_height=8.5,
)
right_panel.draw((85.0, 5.0), width=73.0, height=56.0, align="bottom_left")
r_xy, r_w, r_h = right_panel.get_content_box((85.0, 5.0), width=73.0, height=56.0, padding=(3.0, 2.5))
fallback_cards = CardList(
style=Styles.White,
text_style=Styles.DarkBold.patch(text_size=10.5),
description_style=Styles.Dark.patch(text_size=10.0),
badge_shape="rounded_rect",
badge_style=Styles.SecondaryNeutral,
badge_text_style=Styles.DarkBold.patch(text_size=10.0),
spacing=2.2,
)
fallback_cards.add(
"Subtree Runtime Islands",
"Rolls back Doc & calls runtimeRun()",
badge_text="F1",
)
fallback_cards.add(
"Single-Pass FALLBACK_FLAG",
"Sets ctx[FALLBACK_FLAG] on INVALID",
badge_text="F2",
)
fallback_cards.add(
"CSP & jitless Bypass",
"allowsEval & try/catch use runtime",
badge_text="F3",
)
fallback_cards.draw(r_xy, width=r_w, height=r_h)
save()
4.1 The Doc Code Builder (packages/zod/src/v4/core/doc.ts)
Both $ZodObjectJIT and z.compile() assemble JavaScript source text through the Doc class in packages/zod/src/v4/core/doc.ts. Designed to fit in ~50 lines with zero external dependencies, Doc manages three concerns:
- Closure Environment (
closed: Record<string, unknown>): Tracks runtime references (such asshapedictionaries, compiledRegExpobjects,Setlookup tables, or helper functions) that the generated function must close over without serializing them into source literals. - Exception-Safe Indentation (
indented(fn)): Incrementsthis.indentinside atry ... finallyblock. BecausecompileChild()incompile.tscatchesZodCompileUnsupportedErrorthrown partway through a nested block and rolls backdoc.content.length, unwindingthis.indentinfinallyguarantees that subsequent fallback statements remain properly indented. - Two-Level Factory Compilation (
compile()): Instead of evaluatingeval()in the local lexical scope,Doc.compile()constructs an outer factorynew Function(...Object.keys(this.closed), "return function (" + this.args.join(", ") + ") {\n" + content.join("\n") + "\n};")and immediately invokesfactory(...Object.values(this.closed)).
// packages/zod/src/v4/core/doc.ts
export class Doc {
args: string[];
/** Bindings the compiled function closes over, by name. */
closed: Record<string, unknown>;
content: string[] = [];
indent = 0;
constructor(args: string[] = [], closed: Record<string, unknown> = {}) {
this.args = args;
this.closed = closed;
}
// the compiler catches a child's throw and keeps writing into this doc, so the indent has to unwind with it
indented(fn: (doc: Doc) => void) {
this.indent += 1;
try {
fn(this);
} finally {
this.indent -= 1;
}
}
compile(): any {
const F = Function;
const content = this?.content ?? [``];
const factory = new F(
...Object.keys(this.closed),
`return function (${this.args.join(", ")}) {\n${content.join("\n")}\n};`
);
return factory(...Object.values(this.closed)) as any;
}
}
Why Closing Over shape Beats Passing Parameters in V8 TurboFan
In $ZodObjectJIT (packages/zod/src/v4/core/schemas.ts), generateFastpass(def.shape) initializes new Doc(["payload", "ctx"], { shape, inst, memo, syms }). Closing over shape via the outer new Function factory binds a fixed object reference into the inner function's context slot. V8's Optimizing Compiler (Sparkplug / Maglev / TurboFan) specializes the inner function against that exact shape map and devirtualizes every property call (shape["email"]._zod.run(...)) into a direct known-callee call—measuring ~13% faster than passing shape as a function parameter.
4.2 Tier 1 vs. Tier 2: $ZodObjectJIT vs. z.compile()
Zod v4 separates runtime object loop unrolling from whole-schema Ahead-of-Time (AOT) compilation:
| Dimension | Tier 1: $ZodObjectJIT (schemas.ts) |
Tier 2: z.compile() AOT (compile.ts) |
|---|---|---|
| Trigger Mechanism | Lazy on first synchronous parse when !globalConfig.jitless && allowsEval.value |
Explicit z.compile(schema) or global import "zod/compile" (postProcessor hook) |
| Compilation Scope | Unrolls a single $ZodObject's property loop; calls shape[k]._zod.run() on children |
Recursively inlines the entire $ZodType AST into a single flat JS function |
| Failure Protocol | Mutates payload.issues in-place and prefixes iss.path on failure |
Returns the INVALID symbol (Symbol.for("zod.compile.invalid")) and delegates error construction to the runtime parser |
| Cycle Support | Calls memo.alloc(inst, payload, {}, ctx) when globalConfig.memoizer is active |
Rejects recursive schemas upfront via isRecursiveSchema(schema) |
| Validator Variant | Uses standard ParsePayload path |
Generates a second assertOnly: true function (_zod.bag.validator) for z.validate() that skips output allocation |
4.3 Deep Dive into compile.ts (compileFn, withParser, and Islands)
1. The INVALID Sentinel and Two-Phase Execution (withParser)
Constructing rich $ZodRawIssue objects with accurate paths, expected types, and custom error maps inside generated code would bloat code size and prevent V8 inlining. Instead, compileFn() generates a pure fast-path function with signature (input: unknown) => T | INVALID.
When out !== INVALID, the fast path returns the freshly constructed output value directly—zero ParsePayload or issues array allocations occur. When validation fails (out === INVALID), withParser() delegates to the original runtime parser (originalRun(payload, ctx)) to collect canonical issues:
// packages/zod/src/v4/core/compile.ts
export const INVALID: unique symbol = Symbol.for("zod.compile.invalid");
const FALLBACK_FLAG: unique symbol = Symbol.for("zod.compile.fallback");
export function withParser<T extends SomeType>(schema: T, parser: (input: unknown) => core.output<T> | INVALID): T {
if (isRecursiveSchema(schema as any)) {
throw new ZodCompileUnsupportedError("a schema whose subtree contains a reference cycle");
}
const clone = util.clone(schema as any) as T;
const liveRun = schema._zod.run as ((p: ParsePayload, c: ParseContextInternal) => any) & {
__originalRun?: (p: ParsePayload, c: ParseContextInternal) => any;
};
const originalRun = liveRun.__originalRun ?? liveRun;
const wrapped = (payload: ParsePayload, ctx: ParseContextInternal): any => {
if (
ctx?.async ||
ctx?.direction === "backward" ||
ctx?.skipChecks ||
(ctx as Record<symbol, unknown> | undefined)?.[FALLBACK_FLAG]
) {
return originalRun(payload, ctx);
}
if (ctx && isBackEdge(ctx, payload.value)) {
return originalRun(payload, ctx);
}
const out = parser(payload.value);
if (out !== INVALID) {
payload.value = out;
return payload;
}
if (ctx) (ctx as Record<symbol, unknown>)[FALLBACK_FLAG] = true;
return originalRun(payload, ctx);
};
(wrapped as { __originalRun?: typeof originalRun }).__originalRun = originalRun;
clone._zod.bag.fallbackRun = originalRun;
clone._zod.bag.validator = parser as CompiledFn<unknown>;
clone._zod.run = wrapped;
if (!liveRun.__originalRun) installCompiledUserMethods(clone, schema, parser as CompiledFn<core.output<T>>);
return clone;
}
[!IMPORTANT] Why
FALLBACK_FLAGIs Critical Under Global Compilation: Whenimport "zod/compile"installs compiled wrappers on every schema viaglobalConfig.postProcessor, a parent schema and all of its child property schemas carry compiled wrappers. If the parent fails and falls back tooriginalRun(payload, ctx), settingctx[FALLBACK_FLAG] = trueensures nested child schemas immediately bypass their compiled fast paths during the error-collection pass—guaranteeing user.refine()or.transform()callbacks execute at most twice (once on the fast path, once on the error-reporting fallback) rather than $O(\text{depth})$ times.
2. Subtree Rollback and Runtime Islands (compileChild & emitRuntimeIsland)
Not every Zod construct can be compiled into inline JavaScript (for example, z.coerce.string(), z.xor(), or checks with custom when predicates). Rather than abandoning compilation for an entire parent z.object() when a single field uses an unsupported feature, compileChild() snapshots the Doc and CompileContext cursors before recursing:
// packages/zod/src/v4/core/compile.ts
function compileChild(
doc: Doc,
ctx: CompileContext,
schema: SomeType,
accessor: string,
needsValue = true
): string | null {
const contentLen = doc.content.length;
const constantCount = ctx.constants.size;
const constantCounter = ctx.constantCounter;
const varCounter = ctx.varCounter;
try {
return generateCheck(doc, ctx, schema, accessor, needsValue);
} catch (err) {
if (!(err instanceof ZodCompileUnsupportedError) || !err.islandable) throw err;
doc.content.length = contentLen;
if (ctx.constants.size > constantCount) {
const trailing = Array.from(ctx.constants.keys()).slice(constantCount);
for (const k of trailing) ctx.constants.delete(k);
}
ctx.constantCounter = constantCounter;
ctx.varCounter = varCounter;
return emitRuntimeIsland(doc, ctx, schema, accessor);
}
}
Notice the err.islandable boolean on ZodCompileUnsupportedError:
islandable = true(default): The child subtree is isolated into aruntimeRun(schema, value)black-box call viaemitRuntimeIsland(). If it succeeds, the compiled parent continues at full speed.islandable = false: Used forz.catch(callback)when the callback is not tagged withCONSTANT_CATCH. Because acatchcallback inspectsctx.error(which formats issues using the caller's per-parse error map) and then succeeds, running it inside a context-free runtime island would permanently bake the wrong error messages into the returned value. Thusislandable: falseaborts compilation all the way to the root schema.ZodCompileAsyncError: Thrown when anasyncrefinement or transform is encountered. It bypassescompileChild's catch block so the entire schema stays on the runtime interpreter.
3. Property Read Caching, Prototype Pollution Guards, and Format Allowlists
Inspection of generateObjectCheck and generateStringFormatCheck reveals three security and semantic invariants enforced during codegen:
- Single-Evaluation Getter Caching: For every property
keyingenerateObjectCheck, the compiler emitsconst v0 = input["key"];before running checks or assembling the output literal. If an input object defines a getter (or a Proxy trap), readinginput["key"]once prevents time-of-check to time-of-use (TOCTOU) divergence between validation and output construction. __proto__Prototype Setter Protection: IngenerateObjectCheckandgenerateRecordCheck,__proto__keys are explicitly guarded (if (k === "__proto__") continue;orZodCompileUnsupportedError('object shape key "__proto__"')) so generated object literals and bracket assignments never triggerObject.prototype.__proto__.- Code-Injection & Regex Completeness Guards:
- Count bounds (
min_length,max_length,min_size) pass throughnumericOperand(value, label)which verifiestypeof value === "number" && Number.isFinite(value)before interpolating into generated source text. generateStringFormatCheckuses an explicitPATTERN_IS_COMPLETEallowlist (uuid,email,ipv4,cuid,ulid, etc.) for formats whosedef.patternregex is necessary and sufficient. Formats requiring algorithmic validation beyond regex shape—such asbase64(isValidBase64),ipv6(isValidIPv6),jwt(isValidJWT),credit_card(isValidCreditCardLuhn check),iban(isValidIBANmod-97 check), andurl(validateURL)—hoist the exact runtime validator function intoctx.constantsviaaddConstant().
- Count bounds (
4.4 Cycle Detection & Runtime Memoization (memoizer.ts)
Because compiled functions do not receive the per-parse ParseContextInternal object (ctx), they cannot track cyclical object references across recursive re-entries. Zod v4 centralizes both static schema cycle detection and runtime object graph memoization in packages/zod/src/v4/core/memoizer.ts.
1. The 3-Valued Static Cycle Lattice (NONE, ASSUMED, PROVEN)
Determining whether a $ZodType graph contains a cycle is non-trivial because $ZodLazy getters and getter-backed object shapes can execute arbitrary user code or mint fresh schema instances on every property read. isRecursive(inst, stack, resolve) in memoizer.ts evaluates schemas over a 3-point certainty lattice:
NONE (0): Every reachable child is a leaf or proven acyclic. Settled into the module-levelrecursive: WeakMap<object, boolean>cache asfalse.ASSUMED (1): Encountered an unresolved$ZodLazy(whenresolve === false) or a property getter (desc.get) on an object shape. Because an unresolved lazy getter might resolve to an acyclic tree on the next parse,settle()refuses to cacheASSUMEDin theWeakMap.PROVEN (2): Encountered an ancestor schema already present in the active traversalstack: Set<object>. Settled into theWeakMapcache astrue.
When z.compile() calls isRecursiveSchema(inst), it passes resolve = true so $ZodLazy getters are inspected one hop ahead, rejecting any schema where isRecursive(...) !== NONE.
2. Self-Uninstalling Container Wrappers & $ZodCyclicError
In classic zod, globalConfig.memoizer is enabled by default (whereas zod/mini omits it for bundle size unless opted in via z.config({ memoizer: z.memoizer() })).
- Zero-Cost Acyclic Unhooking: When a container (
$ZodObject,$ZodArray,$ZodRecord,$ZodMap,$ZodSet,$ZodTuple) runs its first parse,memo.attach(inst)callsisRecursive(inst, new Set(), false). If the answer isNONE, the wrapper immediately overwritesinst._zod.parse = baseandinst._zod.run = base—removing itself permanently so subsequent parses of non-recursive containers pay zero memoization overhead. - Pre-Child Placeholder Allocation (
memo.alloc): Before a recursive container parses its children, it sets the module-levelhandoffpointer to the current(ctx["~memo"].buckets.get(inst))WeakMapand callsmemo.alloc(inst, payload, empty, ctx). This registersbucket.set(input, { value: empty, issues: null })and pushes the entry ontoopen. - Back-Edge Detection (
isBackEdge) and Transform Guards (memo.guard):- If a descendant re-enters the same
(inst, input)pair whilehit.issues === null(meaning the ancestor container has not finished parsing its children),memo.attachrecordsstate.backEdges.add(hit.value), setspayload.memo = true(skipping redundant container checks on the unfinished placeholder), and returnshit.valueto close the reference cycle. - However, if that cycle passes through a unidirectional
$ZodTransformor$ZodPipe, the transformed output value does not exist yet when the back-edge binds the placeholder. Therefore,memo.guard(inst)wraps transform schemas and checksif (ctx.direction !== "backward" && isBackEdge(ctx, payload.value)) throw new $ZodCyclicError();, raising a deterministicZodCyclicError("Cannot parse a reference cycle that closes through a transform").
- If a descendant re-enters the same
← 3. ParsePayload & Checks Engine | 5. Registries & JSON Schema →