Skip to content

perf: answer z.validate from the compiled fast path on invalid input - #6538

Merged
colinhacks merged 6 commits into
mainfrom
validate-fast-fail
Aug 31, 2026
Merged

colinhacks merged 6 commits into
mainfrom
validate-fast-fail

Conversation

@colinhacks

@colinhacks colinhacks commented Aug 31, 2026 •

Copy link
Copy Markdown
Owner

z.validate on 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 INVALID already means the runtime would reject — which is why a union can read a branch's INVALID as a rejection. Codegen now tracks a definite flag and validate skips 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 addUserConstant helper 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, and compile-differential pins that.

Invalid input to a compiled callback-free schema, minimum of 7 samples across 3 interleaved rounds:

case before after
failure in the last field 856.2 ns 89.8 ns
failure in the first field 767.7 ns 6.5 ns
valid input (unchanged path) 109.8 ns 110.1 ns

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 validate measures flat to within 0.6% on a separate focused run, including z.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.

Copilot AI lite review requested due to automatic review settings August 31, 2026 20:28

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@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

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 false directly 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.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using GPT Sol | 𝕏

Comment thread packages/zod/src/v4/core/parse.ts Outdated

@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 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 definite bit 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 INVALID is not uniformly definitive and removed the earlier generated throw paths.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using GPT Sol | 𝕏

Comment thread packages/zod/src/v4/core/compile.ts
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.
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.

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

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using GPT Sol | 𝕏

Comment thread packages/zod/src/v4/classic/tests/validate.test.ts
…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.

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

Pullfrog  | View workflow run | Using GPT Sol | 𝕏

@colinhacks
colinhacks merged commit 81ded99 into main Aug 31, 2026
13 checks passed
@colinhacks
colinhacks deleted the validate-fast-fail branch August 31, 2026 21:47
colinhacks added a commit that referenced this pull request Aug 31, 2026
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.
colinhacks added a commit that referenced this pull request Sep 2, 2026
…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.

This branch was successfully deployed

1 active deployment
Preview – zod-v4 — acb6411e Deployed Aug 31, 2026 by vercel[bot]
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