Object parsing is Zod's hottest code path, and v4 treats it accordingly: for each object shape, Zod can generate JavaScript source specialized to that shape and compile it once with new Function. This page explains both paths and the switches between them.

The interpreted baseline

$ZodObject in core/schemas.ts is the straightforward implementation: verify the input is an object, then loop over the known keys, running each property schema against the input value:

packages/zod/src/v4/core/schemas.ts
inst._zod.parse = (payload, ctx) => {
  value ??= _normalized.value;
  const input = payload.value;
  if (!isObject(input)) {
    payload.issues.push({
      expected: "object",
      code: "invalid_type",
      input,
      inst,
    });
    return payload;
  }

  payload.value = {};

  const proms: Promise<any>[] = [];
  const shape = value.shape;

  for (const key of value.keys) {
    const el = shape[key]!;
    const isOptionalIn = el._zod.optin === "optional";
    const isOptionalOut = el._zod.optout === "optional";

    const r = el._zod.run({ value: input[key], issues: [] }, ctx);
    // …
  }

Unknown keys are handled afterwards by handleCatchall: with the default def they are stripped (the output is rebuilt from known keys only), with z.strictObject the catchall is z.never() so extras produce an unrecognized_keys issue, and with z.looseObject they are parsed through the catchall schema. Note the guard in that loop:

packages/zod/src/v4/core/schemas.ts
for (const key in input) {
  // skip __proto__ so it can't replace the result prototype via the
  // assignment setter on the plain {} we build into
  if (key === "__proto__") continue;
  if (keySet.has(key)) continue;

object.test.ts pins this down from every angle: looseObject, .passthrough(), and .catchall(z.unknown()) all drop an injected __proto__ key and leave Object.prototype untouched, while a __proto__ key declared in the shape round-trips as an own data property (that is what the setProp helper in the compiled path exists for).

The compiled fast path

$ZodObjectJIT wraps the same constructor and, on first parse, builds source code specialized to the shape using the tiny code-writer in core/doc.ts:

packages/zod/src/v4/core/schemas.ts
const generateFastpass = (shape: any) => {
  const doc = new Doc(["shape", "payload", "ctx", "setProp"]);
  const normalized = _normalized.value;

  const parseStr = (key: string) => {
    const k = util.esc(key);
    return `shape[${k}]._zod.run({ value: input[${k}], issues: [] }, ctx)`;
  };
  // …
  doc.write(`const newResult = {};`);
  for (const key of normalized.keys) {
    // per-key code emitted here, branching on optionality
  }
  doc.write(`payload.value = newResult;`);
  doc.write(`return payload;`);
  const fn = doc.compile();
  return (payload: any, ctx: any) => fn(shape, payload, ctx, setProp);
};

Instead of looping over keys at parse time, the compiled function contains one straight-line block per key — const key_0 = shape["name"]._zod.run(...) — with the optionality branching (optin/optout, missing-key detection via "name" in input) decided at compile time rather than re-evaluated per call. Key order is preserved, and literal "__proto__" keys are routed through a setProp helper instead of plain assignment.

When the fast path is skipped

The gate is explicit in _zod.parse:

packages/zod/src/v4/core/schemas.ts
if (jit && fastEnabled && ctx?.async === false && ctx.jitless !== true) {
  // always synchronous
  if (!fastpass) fastpass = generateFastpass(def.shape);
  payload = fastpass(payload, ctx);

  if (!catchall) return payload;
  return handleCatchall([], input, payload, ctx, value, inst);
}

return superParse(payload, ctx);

Fallbacks to the interpreted path happen when:

  • z.config({ jitless: true }) is set globally, or { jitless: true } is passed per parse call. Tests in object.test.ts assert both paths return identical results for the same input.
  • util.allowsEval is false. This probe is skipped entirely under jitless (strict CSPs report even a caught new Function as a securitypolicyviolation), and it hard-disables eval when navigator.userAgent includes "Cloudflare", since Workers forbid runtime code generation (packages/zod/src/v4/core/util.ts).
  • The parse is async (ctx.async !== false).

There is a related data-driven fast path for discriminated unions: schemas expose _zod.propValues (the set of literal values each property can take), and $ZodDiscriminatedUnion uses it to pick the matching branch without trying every option.

Related pages

Sources: packages/zod/src/v4/core/schemas.ts, packages/zod/src/v4/core/doc.ts, packages/zod/src/v4/core/util.ts · last synced 2026-08-10 · 2d90846 · version 4.4.3