feat(v4): z.compile — ahead-of-time schema compilation - #6085
Conversation
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).
There was a problem hiding this comment.
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 fromcountOperand) to the non-bigintmultiple_ofbranch. I re-probed all seven operand sites with'0) {} globalThis.PWNED = 1; if (0'— every one refuses at codegen time andglobalThis.PWNEDstaysundefined. The bigint branch still interpolates raw but is behindtypeof def.value === "bigint", which is sound. - Replaced the
Symbol.forcatch tag with the plain string keyCONSTANT_CATCH. Confirmed the recovery on the bundle axis: mini-boolean 2772→2750, mini-string 3077→3053, mini-object 4129→4107 gzipped, andconstantCatchno longer appears in any minified mini bundle. - Relocated
pushIssueabovegenerateCustomRefineCheckand rewrote its doc comment. - Widened the catch refusal message to name the constant-value escape hatch.
- Corrected the
url-no-canparsetest 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") // → throwsgenerateCustomCheck 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.
Claude Opus | 𝕏
| 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. |
There was a problem hiding this comment.
nit: stray em-dash mid-clause from the rewrite.
| // `.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>
There was a problem hiding this comment.
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.custompredicate that returns a thenable, closing the false-accept on the bare schema. - Added
packages/treeshake/bundle-size.test.ts, a gzipped-byte ratchet plus aMUST_NOT_APPEARidentifier check for the three mini fixtures. - Taught the fuzzer's
same()comparator aboutSetandMap. - 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, nov3changes. - 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, pluscanParse 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 defaultpnpm testand CI builds first, so the ratchet is live. - Mutation-tested the fuzzer arms: appending
${outputVar}.clear();ingenerateSetCheckproduces 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.customguard — realPromise,{ then(){} }, callable thenable,Promisesubclass, plaintrue, plainfalse. 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.
Claude Opus | 𝕏
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>
There was a problem hiding this comment.
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 changes — 66bd365..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
$ZodAsyncErrorfrom generated code instead of returningINVALID, via a module-levelthrowAsync()wired into the.refine()codegen, thesuperRefine/checkhelper, andgenerateCustomCheck. - Repaired the duplicated clause in the catch-refusal comment.
- Routed the thenable fixtures through
differential()and taught both it andassertUnionSoundto 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?Claude Opus | 𝕏
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>
There was a problem hiding this comment.
ℹ️ The harness fix is correct and I mutation-proved it. One comment-accuracy nit inline.
Reviewed changes — f2cf3e0..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
assertUnionSoundcompare the interpreted union against the compiled one when the bare schema throws, replacing thecontinuethat 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.
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. |
There was a problem hiding this comment.
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.
| // 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>
There was a problem hiding this comment.
ℹ️ No new issues. The one-line change closes the last open correctness thread and is real coverage, not message polish.
Reviewed changes — 2dbe414..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$ZodAsyncErrorfrom a plain userthrow.
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.
Claude Opus | 𝕏
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>
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.

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 normalZodType(.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 existinggenerateFastpasspattern 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
INVALIDsentinel. OnINVALID, the wrapper re-runs the original interpreter to produce the canonicalZodError. 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 dualAllows/Applycompilation). User.refine/.transformcallbacks run at most twice on invalid input, matching the existing Standard Schema sync-then-async bound.Anything the compiler can't model exactly throws
ZodCompileUnsupportedErrorat 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 respectsconfig().jitlesssoimport "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: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.7xz.string()— onetypeofhas no dispatch to remove. Leaves nested in a container are inlined into the parent and still benefit.z.xor— 1.00x, the bypass checks cost nothing measurableAgainst arktype 2.1.19, matched on contract:
z.object()z.strictObject()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 anew Functionclosure), 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.mddocuments all four; earlier figures inwiki/compile-plan.mdpredate them and shouldn't be quoted.Cost
Three axes, measured against
origin/mainon one machine.Bundle, esbuild
--bundle --minify+ gzip under--conditions=@zod/source:zod-mini-booleanzod-mini-stringzod-mini-objectThat is the
postProcessorhook in$constructor, which is intrinsic to the design and paid whether or notzod/compileis imported.packages/treeshake/bundle-size.test.tsnow 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 wasurlCanParse, 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:
z.string()z.object(5 keys)z.string()z.object(5 keys)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 defaultpnpm 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, andz.record(z.email(), …)accepted every key — both because a string format lives on the def rather than inchecks.generateStringCheckkept a second copy of the format table; it now delegates, and theif (def.pattern)catch-all is an allowlist so an unclassified format loses its fast path instead of silently accepting more than the runtime.__proto__while scanning for unknown keys, bypassing the hardening in fix(v4): report own __proto__ key under .strict() #6221.$ZodCyclicError..default(v).readonly().optional()dropped the default, and.default(v)compiled toundefinedwheneverdefaultValuewas a data property rather than an accessor (which is howdeepPartialstores it).The optional path is now keyed on the
optinladder 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.