Skip to content

feat(v4): z.compile — ahead-of-time schema compilation - #6085

Merged
colinhacks merged 82 commits into
mainfrom
z.compile
Aug 17, 2026
Merged

feat(v4): z.compile — ahead-of-time schema compilation#6085
colinhacks merged 82 commits into
mainfrom
z.compile

Conversation

@colinhacks

@colinhacks colinhacks commented Jun 12, 2026

Copy link
Copy Markdown
Owner

Ahead-of-time compilation for v4 schemas. Two entry points, intentionally non-overlapping:

  • z.compile(schema) — returns a cloned schema whose parse path runs an AOT-compiled fast path. The clone is a normal ZodType (.parse, .safeParse, composition, Standard Schema all work); the original is untouched. Derivations of the clone don't inherit the fast path — compile the final schema.
  • import "zod/compile" — global mode. Installs a post-processor that wraps every newly-constructed schema with a one-shot lazy compile shim (compile on first parse), mirroring the existing generateFastpass pattern so builder-chain intermediates never pay compile cost.

The core design decision is the failure model: the compiled fast path is a happy-path validator that returns the parsed output or an INVALID sentinel. On INVALID, the wrapper re-runs the original interpreter to produce the canonical ZodError. That gives 100% error parity by construction — there is no second error-path codegen to maintain (the main reason this is preferable to arktype-style dual Allows/Apply compilation). User .refine/.transform callbacks run at most twice on invalid input, matching the existing Standard Schema sync-then-async bound.

Anything the compiler can't model exactly throws ZodCompileUnsupportedError at codegen time — no silently-dead fast paths. Containers island unsupported children; unions and discriminated unions deliberately don't island, since a falsely-rejecting compiled branch would corrupt match semantics. Forward direction only, no async, and global mode respects config().jitless so import "zod/compile" is inert under CSP.

Performance

Median 2.4x across a 55-schema benchmark (packages/bench/compile-matrix.ts), scaling with how much work the schema does per parse — what compilation removes is per-node dispatch and allocation, not the checks:

Biggest wins z.array(z.string()) x100 13.9x, z.array(z.object()) x50 9.3x, 20-key object 8.9x, object union 8.4x, .pipe() 7.7x
Typical nested object 4.5x, tuple 4.8x, discriminated union 4.3x, intersection 4.3x, strict object 3.0x, flat 5-key object 2.5x
No win bare z.string() — one typeof has no dispatch to remove. Leaves nested in a container are inlined into the parent and still benefit.
Forced fallbacks recursive schemas, z.xor — 1.00x, the bypass checks cost nothing measurable

Against arktype 2.1.19, matched on contract:

case ark, returns input ark, rejects unknown ark, allocates z.object() z.strictObject()
simple 2-key object 130.0M 13.0M 1.2M 64.3M 44.6M
nested object (moltar) 19.9M 6.0M 174k 30.5M 17.6M

Both rejecting undeclared keys, zod is 2.9x faster on moltar and 3.4x on the simple object. Arktype's fast path validates in place — it neither allocates nor strips, a weaker contract than anything zod offers — and even against that, z.object() wins on moltar (30.5M vs 19.9M), losing only the flat two-key case. Arktype can build fresh output via .onDeepUndeclaredKey("delete"), but that costs it ~20x its own fast path, so that column says more about an unoptimized mode on their side than about validation speed.

Benchmarking this needed four separate corrections before the numbers meant anything: results must escape (a discarded one lets V8 delete the parse outright — z.string() measured 625M ops/sec), inputs must arrive through an array load (a constant makes the call loop-invariant and V8 hoists interpreter code far more readily than a new Function closure), how many schemas share the process is the single largest lever on the result, and runtime/compiled must be interleaved because absolute throughput drifts tens of percent between runs. wiki/compile.md documents all four; earlier figures in wiki/compile-plan.md predate them and shouldn't be quoted.

Cost

Three axes, measured against origin/main on one machine.

Bundle, esbuild --bundle --minify + gzip under --conditions=@zod/source:

fixture main PR delta
zod-mini-boolean 2789 2814 +25 (+0.9%)
zod-mini-string 3115 3136 +21 (+0.7%)
zod-mini-object 4216 4239 +23 (+0.5%)

That is the postProcessor hook in $constructor, which is intrinsic to the design and paid whether or not zod/compile is imported. packages/treeshake/bundle-size.test.ts now holds these fixtures to a ceiling, so a regression fails CI instead of waiting for a reviewer to measure. An earlier measurement put mini at +64; the difference was urlCanParse, a module-scope const whose initializer calls .bind() and so could not be tree-shaken. It resolves on first use now. The ~2000-line compiler itself tree-shakes cleanly.

Construction, schemas built per second, three runs each:

main PR
classic z.string() 1.62M / 1.57M / 1.66M 1.61M / 1.68M / 1.60M
classic z.object (5 keys) 121k / 120k / 126k 127k / 120k / 122k
mini z.string() 1.57M / 1.63M / 1.68M 1.70M / 1.63M / 1.53M
mini z.object (5 keys) 144k / 145k / 156k 132k / 161k / 146k

Every pair overlaps and the differences fall on both sides, so the hook's construction cost is below this harness's noise floor (~±8%). Mini is listed separately because a fixed cost is a larger fraction there.

Parse is the benchmark section above.

Correctness

The differential harness runs the entire v4 test corpus a second time with global compile enabled (vitest.compile.config.ts, wired into the default pnpm test), asserting per fixture that the fast path actually produced the value rather than silently falling back. That's what caught every divergence fixed here:

  • z.creditCard() compiled to its shape regex without the Luhn digit, and z.record(z.email(), …) accepted every key — both because a string format lives on the def rather than in checks. generateStringCheck kept a second copy of the format table; it now delegates, and the if (def.pattern) catch-all is an allowlist so an unclassified format loses its fast path instead of silently accepting more than the runtime.
  • A compiled strict object skipped an undeclared __proto__ while scanning for unknown keys, bypassing the hardening in fix(v4): report own __proto__ key under .strict() #6221.
  • A schema whose subtree contains a cycle followed reference cycles in the input until the stack ran out. Those now go to the runtime, whose memoizer terminates them; a compiled node also bails when handed a back edge so a transform on the cycle still raises $ZodCyclicError.
  • .default(v).readonly().optional() dropped the default, and .default(v) compiled to undefined whenever defaultValue was a data property rather than an accessor (which is how deepPartial stores it).

The optional path is now keyed on the optin ladder from #6419 rather than on the inner's type, which is the structural property it was missing.

Records with a validating key schema also compile now — z.email(), z.string().min(2), z.number(), template literals and key transforms, in strict and loose mode — which was the largest remaining fallback in the corpus.

Full rationale, scope cuts and open questions are in wiki/compile.md.

colinhacks added 30 commits May 5, 2026 08:51
Skip the `key in input` guard on required object properties whose child
fast path doesn't accept absent-as-undefined (paired helpers
`requiresPresenceCheck` / `fastPathAcceptsAbsence`), and drop the tuple
exactOptional force-fallback — the existing optoutStart-tail IIFE
already truncates on absence while the inline present branch lets the
inner schema reject explicit undefined.
Extract isValidIPv6 / isValidCIDRv6 as exported helpers from schemas.ts so
the compiler can call them via addConstant. Previously generateStringCheck
fell through to def.pattern.test() for IPv6/CIDRv6, which accepts values
the runtime rejects (e.g. "0:0:0:0:0:0:0:1:1" matches the IPv6 regex but
new URL("http://[…]") rejects it).

Reclassify the four remaining plain Error throws in compile.ts to
ZodCompileUnsupportedError. They mean "this schema isn't in scope for
AOT, fall back to runtime" — same signal the global shim already uses
for every other unsupported feature.
generateObjectCheck, generateTupleCheck, generateArrayCheck,
generateRecordCheck (value side), generateIntersectionCheck, and the
object's catchall-with-schema path now route each child through a new
compileChild() helper. If a child throws ZodCompileUnsupportedError,
the doc + ctx state is rolled back and a runtime island is emitted in
its place — the child schema is hoisted as a constant and parsed via
runtimeRun() at call time. One unsupported leaf no longer aborts
compilation of the surrounding combinator.

Adds case "catch": inner is routed through compileChild, so an
uncompilable inner still gets catch behavior. When the inner fast
path returns INVALID, a hoisted runtimeCatch() helper runs the inner
runtime, finalizes its issues, and invokes catchValue with a
$ZodCatchCtx-shaped payload.

Union / discriminated-union deliberately skipped: first-match-wins
semantics depend on per-option failure being a runtime parser
failure, not a "couldn't compile this branch" sentinel. Letting
ZodCompileUnsupportedError bubble keeps the global shim's
whole-schema fallback in charge.
Bare z.url() (no hostname/protocol/normalize options) now uses
URL.canParse instead of try/new URL/catch. Invalid input no longer
throws — measured ~50× faster on the invalid path, ~2× on valid.
Options paths preserved.

Feature-detected at module load with a try/new URL/catch fallback
for runtimes that predate URL.canParse (Node <18.17, Safari <17).

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The new fuzzer can't see the schema types it appears to cover, and the z.custom thenable divergence from the last round still reproduces at 93bdb44.

Reviewed changes since the last review (cd152ef..93bdb44):

  • Applied numericOperand (renamed from countOperand) to the non-bigint multiple_of branch. I re-probed all seven operand sites with '0) {} globalThis.PWNED = 1; if (0' — every one refuses at codegen time and globalThis.PWNED stays undefined. The bigint branch still interpolates raw but is behind typeof def.value === "bigint", which is sound.
  • Replaced the Symbol.for catch tag with the plain string key CONSTANT_CATCH. Confirmed the recovery on the bundle axis: mini-boolean 2772→2750, mini-string 3077→3053, mini-object 4129→4107 gzipped, and constantCatch no longer appears in any minified mini bundle.
  • Relocated pushIssue above generateCustomRefineCheck and rewrote its doc comment.
  • Widened the catch refusal message to name the constant-value escape hatch.
  • Corrected the url-no-canparse test comment.
  • Added scripts/compile-fuzz.ts — see the inline comment.

⚠️ Carried over

z.custom still accepts a thenable predicate and silently returns the input. Re-verified by execution at 93bdb44:

z.compile(z.custom(() => Promise.resolve(true))).parse("x")  // → "x"
z.custom(() => Promise.resolve(true)).parse("x")             // → throws

generateCustomCheck guards only on isAsyncFunction, which is syntactic — a non-async function returning a promise passes the check and the generated code treats the truthy promise object as a pass. This is the false-accept direction, so the interpreter fallback never runs and nothing surfaces. A thenable check on the returned value (refuse at codegen if you can't see it, or INVALID on a thenable at runtime) closes it. Not re-arguing the rest of the prior round's open items here; they were re-verified as still live but haven't changed.

packages/docs/content/compile.mdx:85 lists z.xor(), custom when and recursive schemas as the refusals, but coercion and catch-with-callback also throw and aren't mentioned. Worth adding now that the catch message advertises the constant-value workaround.

ℹ️ Nitpicks

One inline, on a stray em-dash introduced in this round's comment rewrite.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread scripts/compile-fuzz.ts
Comment thread packages/zod/src/v4/core/compile.ts Outdated
catchValue: (ctx: any) => unknown;
};

// `.catch(value)` synthesises a tagged thunk for a constant, so anything untagged is a user callback. Every callback is refused, not just one that reads `ctx.error`: whether it does is undecidable here, and a callback that reads it needs — issues finalized against the caller's per-parse error map, which generated code never sees. Producing them here gave a different message than `.parse(input, { error })` and, because catch *succeeds*, nothing downstream could notice. Refuse at codegen instead: returning INVALID would be a bail-out, and a union reads that as a rejected branch rather than a reason to hand the whole parse back.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: stray em-dash mid-clause from the rewrite.

Suggested change
// `.catch(value)` synthesises a tagged thunk for a constant, so anything untagged is a user callback. Every callback is refused, not just one that reads `ctx.error`: whether it does is undecidable here, and a callback that reads it needs issues finalized against the caller's per-parse error map, which generated code never sees. Producing them here gave a different message than `.parse(input, { error })` and, because catch *succeeds*, nothing downstream could notice. Refuse at codegen instead: returning INVALID would be a bail-out, and a union reads that as a rejected branch rather than a reason to hand the whole parse back.
// ...and a callback that reads it needs issues finalized against the caller's per-parse error map, which generated code never sees.

`generateCustomCheck` guarded only on `isAsyncFunction`, which is
syntactic — a plain function returning a promise reached the generated
code, where the truthy promise read as a pass. That is the false-accept
direction, so the fallback never ran:

  z.compile(z.custom(() => Promise.resolve(true))).parse("x")  // "x"
  z.custom(() => Promise.resolve(true)).parse("x")             // throws

It now tests the returned value, exactly as the `.refine()` path has.

The fuzzer was blind to the Set and Map its own grammar generates: the
comparison fell through to a `Reflect.ownKeys` walk, which sees `[]` for
both, so any two Sets compared equal. Mutation-tested the same way it was
found — adding `.clear()` to the Set codegen now produces 5 failures per
seed where the fuzzer previously reported none.

Also lists coercion and catch-with-callback among the documented refusals,
which the docs still omitted after those refusals were added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The em-dash fix appended the replacement clause without removing the
original, leaving the sentence duplicated and the em-dash still in place —
so the thread it was meant to close was still accurate. Rewritten as one
sentence, and I swept the file for the same damage elsewhere (none).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The thenable guard added this round returns INVALID where the interpreter throws. Inside a union that reads as a rejected branch, so a later branch answers instead — the exact failure class this PR's union harness exists to catch. My previous review recommended INVALID here; that advice was wrong.

Reviewed changes (93bdb44..66bd365 — two commits; the ae7a8b4 merge only brings #6429 from main)

  • Rejected a z.custom predicate that returns a thenable, closing the false-accept on the bare schema.
  • Added packages/treeshake/bundle-size.test.ts, a gzipped-byte ratchet plus a MUST_NOT_APPEAR identifier check for the three mini fixtures.
  • Taught the fuzzer's same() comparator about Set and Map.
  • Completed the documented refusal list with coercion and .catch() with a callback.

Verification

  • 7291 tests across 561 files green (--project zod --project compile-mode), no type errors, clean tree, no version bump, no v3 changes.
  • Mutation-tested the bundle guard: restoring the module-scope urlCanParse .bind(URL) form and rebuilding fails all 4 tests (2860/2830, 3182/3155, 4286/4260, plus canParse reached a bundle that only uses z.boolean()). Real headroom on clean code is 16 / 19 / 21 gzipped bytes — 0.5–0.6%, matching the PR body exactly. It runs in default pnpm test and CI builds first, so the ratchet is live.
  • Mutation-tested the fuzzer arms: appending ${outputVar}.clear(); in generateSetCheck produces 5 divergences (Set size 2 vs 0). Reverted, rounds=4000 compiled=3828 skipped=172 parseChecks=22968, no divergences.
  • Probed six predicate shapes against the new z.custom guard — real Promise, { then(){} }, callable thenable, Promise subclass, plain true, plain false. Compiled matches the interpreter on all six for the bare schema.

The instanceof Promise choice is right, incidentally: the interpreter uses bare instanceof Promise too, so the duck-typed thenables pass on both sides. The problem is only what happens after the guard fires.

ℹ️ Nitpicks

The new fixture uses a bespoke expectBothThrow rather than differential(), so it never reaches assertUnionSound — which is why the union divergence survived the test that was written for it. Routing it through the standard harness would have caught this.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread packages/zod/src/v4/core/compile.ts Outdated
Comment thread packages/zod/src/v4/core/compile.ts Outdated
A predicate that hands back a thenable is an async check reached
synchronously, and the interpreter throws `$ZodAsyncError` for it.
Returning INVALID is a *bail-out*, not a rejection — and inside a union a
bail-out reads as a failed branch, so a later branch answers where the
interpreter throws:

  z.union([z.custom(() => Promise.resolve(true)), z.any().transform(() => "S")])
    .parse("x")   // "S" compiled, throws interpreted

Three sites: `z.custom`, `.refine()` and `.superRefine()`. The two
transform sites are deliberately left alone — there the interpreter's own
union also falls through, so INVALID is parity rather than a bail-out. I
verified all five before changing any of them.

The fixture that shipped this used a bespoke throw-comparison helper
instead of `differential()`, so it never reached `assertUnionSound` — the
harness this PR added for exactly this class. It goes through
`differential()` now, which in turn learned to compare *throws* between
the two parsers rather than assuming both return.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The code fix is correct — I verified all six thenable shapes against the interpreter, bare and inside a union. But the test written for it does not detect its absence: reverting all three throwAsync() guards back to return INVALID leaves the suite green at 152/152 while the divergence fully reappears.

Reviewed changes66bd365..f2cf3e0, two commits, two files (compile.ts +24/−13, compile-differential.test.ts +60/−31). Both prior threads addressed and retired.

  • Made a thenable predicate throw $ZodAsyncError from generated code instead of returning INVALID, via a module-level throwAsync() wired into the .refine() codegen, the superRefine/check helper, and generateCustomCheck.
  • Repaired the duplicated clause in the catch-refusal comment.
  • Routed the thenable fixtures through differential() and taught both it and assertUnionSound to compare throws rather than letting them escape.

Verification — 7291 tests across 561 files green (--project zod --project compile-mode), no type errors, clean tree, no version bump, no v3 changes. Probed custom, refine, superRefine, check, transform and pipe(transform) with a thenable, each bare and as z.union([target, z.any().transform(() => "SENTINEL")]) — all twelve now agree with the interpreter. The prior round's read that transform and pipe need no change holds up: the interpreter's own union answers "SENTINEL" there, so INVALID is parity rather than a bail-out. addConstant dedupes by identity, so throwAsync costs one constant slot per compiled function regardless of site count, and no try/catch is ever emitted into generated code — the three in compile.ts are all codegen-time — so the throw propagates cleanly out of a compiled union.

⚠️ A bare-schema differential is structurally blind to the bug this round fixed

Worth stating as a property of the harness rather than as one line: differential()'s new throw-parity check runs the bare schema, and on INVALID the fallback wrapper re-runs the interpreter and reproduces the same throw. So the compiled and runtime sides agree no matter which way the guard is written. Only assertUnionSound can observe this class, which makes each of its skip paths a coverage hole — and it now has three, none counted: codegen refusal (:113), a throwing schema (:119), and a throwing compiled union (:121). That last one is new this round and silently swallows a real divergence, since it is reached only after the direct schema has already succeeded.

Technical details
# `assertUnionSound` skip paths are uncounted, and one is new

## Affected sites
- `packages/zod/src/v4/core/tests/compile-differential.test.ts:113``catch { return; }` on codegen refusal exempts the whole fixture, no signal.
- `packages/zod/src/v4/core/tests/compile-differential.test.ts:119``if (direct.threw || …) continue` exempts exactly the inputs where a union bail-out is observable.
- `packages/zod/src/v4/core/tests/compile-differential.test.ts:121``if (attempted.threw) continue` is reached only when the direct schema succeeded, so a compiled union that throws there is a genuine divergence being discarded. Before this round it would have failed the test loudly.

## Required outcome
- A fixture cannot be silently exempt from the union harness. Either the skips are counted and held to a ceiling, or the throwing cases become assertions instead of `continue`.

## Open questions for the human
- `compile-stats.test.ts` still asserts only that five trivial schemas compiled, so there is no corpus-wide fallback ceiling either. Is a combined "how much of the corpus is actually under the union harness" assertion in scope for this PR, or better filed separately given the branch size?

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread packages/zod/src/v4/core/tests/compile-differential.test.ts Outdated
Comment thread packages/zod/src/v4/core/tests/compile-differential.test.ts Outdated
The fixture written for the thenable fix did not detect its absence:
reverting all three `throwAsync()` guards to `return INVALID` left the
suite green while the divergence fully reappeared.

The reason is a property of the harness, not one bad line. A bare
differential cannot see this class at all — the fast path returns
INVALID, the wrapper re-runs the interpreter, and both sides reproduce
the same throw whichever way the guard is written. Only the union can
observe it, and the skip path I added last round for throwing schemas
swallowed exactly the case it needed to judge, with a comment asserting
the opposite.

A schema that throws now requires the compiled union to throw as the
interpreted union does, and a compiled union that throws for input the
schema accepts is a failure rather than a skip.

Mutation-tested: reverting the three guards now fails with "union
disagreed on throwing for input 'x': interpreted Error, compiled did not
throw".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ The harness fix is correct and I mutation-proved it. One comment-accuracy nit inline.

Reviewed changesf2cf3e0..2dbe414, one commit, one file (compile-differential.test.ts +18/−5). Range derived from list_pull_request_reviews; the provided incremental range agreed this round.

  • Made assertUnionSound compare the interpreted union against the compiled one when the bare schema throws, replacing the continue that exempted precisely the inputs on which a union bail-out is observable.
  • Turned a throwing compiled union into an assertion on input the schema accepts, where it was previously a silent continue.

Verification. The point of this round was whether the test can now fail, so I mutation-tested it rather than read it: reverting all three throwAsync() guards in compile.ts back to return INVALID now fails at compile-differential.test.ts:126 with union disagreed on throwing for input "x": interpreted Error, compiled did not throw. Last round the identical mutation left the suite green at 152/152. union at :111 is finally parsed with rather than dead.

I also checked whether the surviving continue at :127 hides a value divergence — the thenable-transform fixture at :667 reaches it with neither union throwing, and the two union results are never compared. It does not. With only two branches, a compiled branch that answers instead of bailing also breaks the bare throw-parity check; mutating both transform sites (compile.ts:1895, :1999) to leak the promise is caught at :69, not silently passed. So that skip is safe as written.

7291 tests across 561 files green (--project zod --project compile-mode), no type errors, clean tree, no version bump, no v3 changes.

Two of the three assertUnionSound skip paths from the last review are closed here. The codegen-refusal return at :114 is unchanged, so I haven't re-anchored it, and the attempt() .name thread stays open — the failure message above printing interpreted Error rather than interpreted $ZodAsyncError is that thread showing its cost.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

const viaUnion = attempt(() => compiledUnion.safeParse(input));

if (direct.threw) {
// The schema throws out of the whole parse, so the interpreted union does too. If the compiled one *answers* instead, a bail-out was read as a rejected branch. Nothing else in this file can see that: the bare differential compares a fast path that returned INVALID against a fallback that re-runs the interpreter, so both sides reproduce the throw either way.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first clause is contradicted by this file's own fixture at :667: a thenable transform throws $ZodAsyncError out of the bare schema, but the interpreted union answers Symbol(sentinel) (verified by execution — refine/superRefine/custom propagate, transform/pipe do not). The code is right to measure interpreted rather than assume it, and this is the sentence explaining why the assertion exists, so it's the one place that shouldn't say the opposite.

Suggested change
// The schema throws out of the whole parse, so the interpreted union does too. If the compiled one *answers* instead, a bail-out was read as a rejected branch. Nothing else in this file can see that: the bare differential compares a fast path that returned INVALID against a fallback that re-runs the interpreter, so both sides reproduce the throw either way.
// The bare schema throws, so the interpreted union is the only reference for what the compiled one should do: a thenable refine/custom propagates out of the union, while a thenable transform lets the next branch answer. If the two disagree, a bail-out was read as a rejected branch. Nothing else in this file can see that: the bare differential compares a fast path that returned INVALID against a fallback that re-runs the interpreter, so both sides reproduce the throw either way.

`$ZodAsyncError` never sets `.name`, so it reads as plain `"Error"` — the
same string a user `throw new Error()` produces. Both throw-parity
assertions compared that string, so a swapped error class was
indistinguishable from a match.

`constructor.name` is exact. Mutation-tested: changing `throwAsync` to
throw a plain `Error` with the identical message now fails, where the
previous comparison saw the two as equal.

(One detail of the report was inexact — `$ZodCyclicError` does set
`.name`. `$ZodAsyncError` is the one that doesn't, and it is the one both
assertions actually compare.)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comment said a schema that throws means the interpreted union throws
too. This file's own fixture disproves it: a thenable `transform` throws
`$ZodAsyncError` bare, yet the interpreted union answers the sentinel.
Verified all five — `refine`, `superRefine` and `custom` propagate out of
a union; `transform` and `pipe` do not.

The code was already right, measuring the interpreted union rather than
assuming. Only the sentence explaining why the assertion exists said the
opposite, which is the worst place for it to be wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No new issues. The one-line change closes the last open correctness thread and is real coverage, not message polish.

Reviewed changes2dbe414..cde7e30, one commit, one file (compile-differential.test.ts, +2/−2). Range from list_pull_request_reviews; the provided incremental range agreed.

  • Made attempt() report the error's class rather than its .name, so the two throw-parity assertions can tell $ZodAsyncError from a plain user throw.

Verification. The claim worth testing is whether this changes what the suite can catch, so I mutation-tested both directions rather than reading it. Swapping throwAsync() at compile.ts:537 to throw new Error(<same message>) — a pure class swap, identical text — now fails at :69 with throw mismatch for input "x": runtime $ZodAsyncError, compiled Error. Reverting attempt() to (err as Error).name with that same mutation still in place goes green at 152/152. So the old form was blind to an error-class swap on the one path that throws, and the new one is not.

The doc comment's two factual claims both hold: $ZodAsyncError (core/core.ts:131) sets no .name and inherits "Error", and $constructor overrides name on the generated class, so constructor.name stays exact for trait-built errors too. The ?. on err covers a thrown null/undefined or a null-prototype object; a thrown primitive now reads "String" instead of "Error", which is symmetric across both sides and so can't produce a false failure.

7291 tests across 561 files green (--project zod --project compile-mode), no type errors, clean tree, no version bump, no v3 changes. The .name thread is resolved above; the comment-accuracy nit at :121 from the last round is unchanged and stays open, and I haven't re-anchored the codegen-refusal skip at :114.

Pullfrog  | Fix it ➔View workflow run | Using Claude Opus𝕏

@colinhacks
colinhacks merged commit fa77a4d into main Aug 17, 2026
10 checks passed
@colinhacks
colinhacks deleted the z.compile branch August 17, 2026 18:46
colinhacks added a commit that referenced this pull request Aug 17, 2026
The first cut left 16-21 gzipped bytes of headroom, which was too tight to
hold. #6085 and #6426 each added ~20 to every fixture and landed hours
apart; neither would have crossed a ceiling alone, and together they took
`zod-mini-string` to 3158 against 3155. `zod-mini-boolean` also finished
sitting exactly on its ceiling and `zod-mini-object` two bytes under, so
fixing only the fixture that failed would have left the other two to break
on the next commit.

Re-measured all three and set headroom to ~28 bytes. Verified that still
catches what the guard is for: appending a module-scope `Object.freeze`
call to `core/util.ts` — the same shape as the two real leaks — adds 32-33
bytes and trips all three ceilings.

A byte ceiling can only approximate "nothing reached a bundle that cannot
use it"; `MUST_NOT_APPEAR` names that exactly. The comment now says so,
rather than implying a tighter number is strictly better.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
colinhacks added a commit that referenced this pull request Aug 19, 2026
z.compile() and zod/compile landed in #6085, after the 4.4.3 release.
zod@latest has no ./compile export, so the live page at zod.dev/compile
documents an API a reader on the stable release cannot call.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants