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

04_jit_compiler_engine_1
Figure 4.1: Zod v4 Doc Codegen & Two-Tier JIT/AOT Compilation Pipeline
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:

  1. Closure Environment (closed: Record<string, unknown>): Tracks runtime references (such as shape dictionaries, compiled RegExp objects, Set lookup tables, or helper functions) that the generated function must close over without serializing them into source literals.
  2. Exception-Safe Indentation (indented(fn)): Increments this.indent inside a try ... finally block. Because compileChild() in compile.ts catches ZodCompileUnsupportedError thrown partway through a nested block and rolls back doc.content.length, unwinding this.indent in finally guarantees that subsequent fallback statements remain properly indented.
  3. Two-Level Factory Compilation (compile()): Instead of evaluating eval() in the local lexical scope, Doc.compile() constructs an outer factory new Function(...Object.keys(this.closed), "return function (" + this.args.join(", ") + ") {\n" + content.join("\n") + "\n};") and immediately invokes factory(...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_FLAG Is Critical Under Global Compilation: When import "zod/compile" installs compiled wrappers on every schema via globalConfig.postProcessor, a parent schema and all of its child property schemas carry compiled wrappers. If the parent fails and falls back to originalRun(payload, ctx), setting ctx[FALLBACK_FLAG] = true ensures 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 a runtimeRun(schema, value) black-box call via emitRuntimeIsland(). If it succeeds, the compiled parent continues at full speed.
  • islandable = false: Used for z.catch(callback) when the callback is not tagged with CONSTANT_CATCH. Because a catch callback inspects ctx.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. Thus islandable: false aborts compilation all the way to the root schema.
  • ZodCompileAsyncError: Thrown when an async refinement or transform is encountered. It bypasses compileChild'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:

  1. Single-Evaluation Getter Caching: For every property key in generateObjectCheck, the compiler emits const v0 = input["key"]; before running checks or assembling the output literal. If an input object defines a getter (or a Proxy trap), reading input["key"] once prevents time-of-check to time-of-use (TOCTOU) divergence between validation and output construction.
  2. __proto__ Prototype Setter Protection: In generateObjectCheck and generateRecordCheck, __proto__ keys are explicitly guarded (if (k === "__proto__") continue; or ZodCompileUnsupportedError('object shape key "__proto__"')) so generated object literals and bracket assignments never trigger Object.prototype.__proto__.
  3. Code-Injection & Regex Completeness Guards:
    • Count bounds (min_length, max_length, min_size) pass through numericOperand(value, label) which verifies typeof value === "number" && Number.isFinite(value) before interpolating into generated source text.
    • generateStringFormatCheck uses an explicit PATTERN_IS_COMPLETE allowlist (uuid, email, ipv4, cuid, ulid, etc.) for formats whose def.pattern regex is necessary and sufficient. Formats requiring algorithmic validation beyond regex shape—such as base64 (isValidBase64), ipv6 (isValidIPv6), jwt (isValidJWT), credit_card (isValidCreditCard Luhn check), iban (isValidIBAN mod-97 check), and url (validateURL)—hoist the exact runtime validator function into ctx.constants via addConstant().

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.

04_jit_compiler_engine_2
Figure 4.2: Static Schema Cycle Lattice & Runtime Back-Edge Memoization Protocol (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-level recursive: WeakMap<object, boolean> cache as false.
  • ASSUMED (1): Encountered an unresolved $ZodLazy (when resolve === 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 cache ASSUMED in the WeakMap.
  • PROVEN (2): Encountered an ancestor schema already present in the active traversal stack: Set<object>. Settled into the WeakMap cache as true.

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() })).

  1. Zero-Cost Acyclic Unhooking: When a container ($ZodObject, $ZodArray, $ZodRecord, $ZodMap, $ZodSet, $ZodTuple) runs its first parse, memo.attach(inst) calls isRecursive(inst, new Set(), false). If the answer is NONE, the wrapper immediately overwrites inst._zod.parse = base and inst._zod.run = base—removing itself permanently so subsequent parses of non-recursive containers pay zero memoization overhead.
  2. Pre-Child Placeholder Allocation (memo.alloc): Before a recursive container parses its children, it sets the module-level handoff pointer to the current (ctx["~memo"].buckets.get(inst)) WeakMap and calls memo.alloc(inst, payload, empty, ctx). This registers bucket.set(input, { value: empty, issues: null }) and pushes the entry onto open.
  3. Back-Edge Detection (isBackEdge) and Transform Guards (memo.guard):
    • If a descendant re-enters the same (inst, input) pair while hit.issues === null (meaning the ancestor container has not finished parsing its children), memo.attach records state.backEdges.add(hit.value), sets payload.memo = true (skipping redundant container checks on the unfinished placeholder), and returns hit.value to close the reference cycle.
    • However, if that cycle passes through a unidirectional $ZodTransform or $ZodPipe, the transformed output value does not exist yet when the back-edge binds the placeholder. Therefore, memo.guard(inst) wraps transform schemas and checks if (ctx.direction !== "backward" && isBackEdge(ctx, payload.value)) throw new $ZodCyclicError();, raising a deterministic ZodCyclicError ("Cannot parse a reference cycle that closes through a transform").

← 3. ParsePayload & Checks Engine | 5. Registries & JSON Schema →