Repository navigation
perf: answer z.validate from the compiled fast path on invalid input - #6538
Conversation
There was a problem hiding this comment.
Important
COMPILE_INVALID is not yet definitive, so the new early return changes two throwing validations into false.
Reviewed changes the compiled z.validate() rejection path, the compiler paths intended to make INVALID definitive, and the accompanying tests and documentation.
- Compiled rejection short-circuit: returns
falsedirectly after a compiled rejection when no parse context is supplied. - Throw parity: runtime islands now throw on thenables, and generated intersections share the interpreter's unmergeable-output error helper.
- Coverage and contract: tightens callback-count assertions, adds throw-parity cases, and documents the stronger sentinel invariant.
GPT Sol | 𝕏
There was a problem hiding this comment.
Important
The new definiteness gate still misses an independently compiled record-key validator, so one throwing validation is converted to false.
Reviewed changes since the prior Pullfrog review, I reviewed the new static definiteness tracking and its fallback coverage.
- Added definiteness tracking: attached a
definitebit to generated validators and gated the compiled rejection shortcut on it. - Restored fallback cases: cleared the bit for runtime islands, intersections, and transforms/pipes, with regressions covering the two previously reported mismatches.
- Updated the contract: documented that
INVALIDis not uniformly definitive and removed the earlier generated throw paths.
GPT Sol | 𝕏
The compiled validator's INVALID sentinel was treated as "could not decide", so every rejection re-ran the whole schema through the interpreter to reach the same verdict. Two sites made that necessary: an async child reached synchronously and an unmergeable intersection both returned INVALID for a condition the interpreter throws on. Both now throw, so INVALID strictly means "the runtime would reject" and validate can answer false directly. The intersection error is hoisted into a shared throwUnmergable so generated code and the interpreter raise the identical error.
Review caught two cases where a compiled INVALID does not mean the runtime would reject, so the early false diverged from the interpreter: a transform handing back a thenable, and an intersection whose child fails before the merge is reached. Making those throw was the wrong fix — compile-differential pins the transform case deliberately, because the interpreter's own union falls through there rather than propagating. Codegen now tracks a definite flag instead, cleared by transforms, runtime islands and intersections, and validate takes the shortcut only when it survives. Compiled parsing semantics are unchanged.
Guards the definite flag: a later generator that answers INVALID for a case the interpreter throws on has to clear it, and this fails if it does not.
5c0aba3 to
558b746
Compare
Review found a compiled record key, which compiles in its own context, leaving the outer validator marked definite. Fixing that exposed the general case: any user callback can throw, and generated code can reject an earlier sibling before ever reaching it, so the rejection is not proof that the interpreter would have rejected rather than thrown. User code now enters generated code through addUserConstant, which clears the flag, so a later generator cannot miss it. The surviving fast-fail set is the callback-free subset, which is the shape a type guard is usually asked about.
There was a problem hiding this comment.
Important
The new parity matrix still leaves one definite boundary untested.
Reviewed changes since the prior Pullfrog review, I reviewed the new compiled-versus-interpreted validation matrix and its coverage of compiler definiteness boundaries.
- Added differential validation coverage: compared compiled and interpreted verdicts or thrown error classes across supported schema and input pairs.
- Exercised known fallback semantics: covered direct promise-returning transforms and both intersection throw cases while retaining the focused runtime-island regression.
- Broadened schema coverage: included objects, collections, unions, wrappers, coercions, refinements, and ordinary pipes in one table-driven check.
GPT Sol | 𝕏
…gresses The matrix passed with any single flag site reverted: the failing-sibling inputs were never added, codecs never reached the pipe branch, superRefine never reached the other refine branch, and every intersection case carried a transform that cleared the flag anyway. Each of the ten sites now has a case that fails when that site alone stops clearing the flag.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes since the prior Pullfrog review, I reviewed the nested record-key definiteness fix and the completed branch-specific differential coverage.
- Propagated nested definiteness: carried an independently compiled record key's non-definitive result into the outer validator.
- Centralized callback handling: routed hoisted user callbacks through one helper that preserves the interpreted fallback when generated rejection can skip a throw.
- Completed parity coverage: added promise-returning codec, callback-free intersection, alternate refinement, and earlier-rejection cases that exercise every definiteness boundary.
GPT Sol | 𝕏
Merging main brought #6538's test, which compiles a cold z.lazy(() => z.string()). Refusing every unresolved lazy made z.compile reject any schema containing one, since compile never parses and so nothing ever resolves it for them. Follow the edge once. Resolving without limit puts the overflow back — `() => z.lazy(() => z.array(N()))` walks forever, and compile-mode made that a RangeError rather than a refusal — so `resolve` is off beyond the first hop, and a lazy that yields only another unresolved lazy is generative and stops there. Such a schema still parses; only z.compile declines it.
…inite guard (#6551) #6544 gave the interpreted parse the same first-failure exit that generated code has always had, which makes it look like the `definite` guard added in #6538 is now redundant: if both settle on the first failure, a compiled `INVALID` and an interpreted `false` agree, and `z.validate` could take the fast path for any schema carrying a `.refine()`. It is not redundant, and the reason is the check chain rather than the sibling walk. A continuable issue does not settle the interpreter's walk, so it runs the rest of the chain: ```ts z.validate(z.string().min(5).refine(boom), "ab"); // throws: .min() is continuable, so the refine still runs ``` Generated code returns `INVALID` at the failed `.min()` and never reaches the refine. Drop the guard and that pair diverges — compiled answers `false` where the interpreter throws. Measured with the guard removed: 6 divergences across `.refine()`, `.superRefine()`, `z.custom()`, string and number ranges, and `z.email()`; with it restored, zero. Two rows in the parity fuzz now cover it. Both branches of the refine generator already throw on a thenable, so nothing else in that generator would have caught this. The failure-model paragraph in `wiki/compile.md` named the sibling case as the reason. That premise is stale after #6544, so it now names the check chain and says outright that the sibling case alone would no longer justify the flag.

z.validateon a compiled schema re-ran the entire schema through the interpreter whenever the compiled validator rejected, just to reach the verdict the fast path had already produced.Often that second pass is dead work, because the compiled
INVALIDalready means the runtime would reject — which is why a union can read a branch'sINVALIDas a rejection. Codegen now tracks adefiniteflag andvalidateskips the interpreted pass only while it survives. A call that passes a parse context still takes the interpreted path, since a context can change the verdict.Two things clear the flag. Any hoisted user callback — a refine, transform, custom predicate, catch value or lazy getter — clears it, because that callback can throw and generated code can reject an earlier sibling before ever reaching it, so the rejection is no longer proof that the interpreter would have rejected rather than thrown. A runtime island, an intersection, and a record key whose own compile is not definite clear it directly. User code reaches generated code through a single
addUserConstanthelper that clears the flag, so a generator added later cannot quietly miss it.What keeps the fast path is the callback-free subset: type, range and format checks, enums, literals, unions, and object, array and tuple members. That is the shape a type guard is usually asked about.
Compiled parsing semantics are untouched. The transform case is why this is a flag rather than a throw in generated code: a transform handing back a thenable has to keep answering
INVALID, because the interpreter's own union falls through to the next branch there instead of propagating, andcompile-differentialpins that.Invalid input to a compiled callback-free schema, minimum of 7 samples across 3 interleaved rounds:
Failure position sets the size of the win: the compiled validator stops at the first bad field, while the interpreted pass it used to trigger walked the whole value collecting every issue. Uncompiled
validatemeasures flat to within 0.6% on a separate focused run, includingz.string()at ~5 ns/op where any loss of inlining would show immediately.Parity is fuzzed rather than argued: every risky construct in every container, against the interpreter, comparing the verdict and the thrown error class. The new test is that fuzz, and disabling the flag makes it fail.
Bundle: classic unchanged,
zod/mini+2 B gz. Schema memory footprints are byte-identical.