SIGN IN SIGN UP

perf(v4): settle z.validate on the first failure in parse order (#6544)

Today `z.validate()` walks the whole schema even after the answer is settled. On a 20-key object with a wrong value in the first key, it still parses the other 19 — the boolean was decided at key 0, and everything after it is work nobody reads.

This gives the runtime parse path an early exit and lets `validate` / `validateAsync` take it. Nothing else does, and there is no new public surface: the context field is internal, and the commented-out `abortEarly` on the public `ParseContext` stays commented out.

The asymmetry is what makes it safe. Reporting every issue and handing back a well-formed value is what `safeParse` owes its caller, so it can never stop early. A boolean is all `validate` returns, so the truncated value a stopped container leaves behind is never read by anyone.

### The contract

First failure in parse order wins. Once an aborting issue settles the boolean, the container stops before the next child it would have parsed, so a callback there never runs — exactly as if it were not in the schema:

```ts
const schema = z.object({
  a: z.number(),
  b: z.string().transform(() => { throw new RangeError(); }),
});
schema.safeParse({ a: "bad", b: "ok" }); // throws: it walks the whole shape
z.validate(schema,  { a: "bad", b: "ok" }); // false: `a` settled it, `b` never ran
```

A throw that does happen still bubbles, unchanged. Nothing rejects before the callback below, so `validate` runs it and the exception is the caller's:

```ts
z.validate(z.object({ a: z.string().transform(boom), b: z.number() }), { a: "ok", b: "bad" }); // throws
```

The stop lands between children, never inside one. A map entry parses its key and value together, and a tuple runs every fixed item before `handleTupleResults` decides which issues survive — so neither can skip the other half, and both stay at `safeParse` parity. Stopping inside them would risk a discarded issue turning a `false` into a `true`, which is not worth the few nanoseconds. A test pins both.

### Numbers

Measured against `main`, interpreted, with a main-vs-main control interleaved into every round.

| schema | valid | invalid |
| --- | --- | --- |
| `z.object()`, 20 keys | 1.00x | **5.3x** |
| `z.array(z.object())`, 10 | 1.00x | **3.2x** |
| set / map, 40 entries | 0.93–0.97x | 1.77–2.08x |
| tuple + rest, 40 | 0.97x | 1.55x |
| nested objects, bad leaf | 0.99x | 1.19x |

Valid input measures a median of 0.996x across the sweep. Compiled schemas are unaffected in both directions.

### What it costs the parse path

Neither `parse` nor `safeParse` sets the flag, so the guards are dead weight for them. The cost is bounded rather than merely unmeasured: parsing a 20,000-element container runs the per-element guard 20,000 times, so a cost of 1 ns per element would show as +25% on `z.array(z.number())`. Over 8 interleaved rounds it measures −0.27%, with `set` at +0.88% and `z.array(z.string())` at −1.21% — every delta inside its own noise band, putting the per-element cost under a nanosecond.

Two reasons it lands there. The generated object parser's guard sites all sit inside `if (id.issues.length)`, so a valid object parse never evaluates the flag at all — structurally zero, not statistically zero. And the interpreted containers hoist the context read out of the loop, leaving a perfectly-predicted always-false register test per child.

Measure this interleaved. A single non-interleaved run reported a 5% regression on `z.array(z.object())` that does not exist; that row's own run-to-run spread is 17%.

**Three axes.** Runtime above. Memory: every row of the schema-footprint bench is identical to `main`, with no new own properties and no dictionary-mode demotion. Bundle: `zod-full` +148, `zod-boolean` +26, `zod-string` +26, `zod-mini-object` +78, `zod-mini-simple-object` +71, and `zod-mini-boolean` unchanged. The `zod-mini-object` ceiling moves from 4629 to 4707.

### Verification

Differential fuzz over 747 sync and 753 async schema/input pairs — containers, intersections over records and strict objects, pipes, codecs, catch/optional/default/prefault/nonoptional, unions, lazy recursion, cyclic input, and every route by which a callback can throw. The invariant allows `validate` to answer `false` where `safeParse` throws, and nothing else; answering `true` where `safeParse` does not succeed is always a failure. Zero divergent.

One correctness bug is worth calling out because the reviews of the earlier `abortEarly` PR did not catch it: a record's `invalid_key` aborts *and* is reconcilable, so a record loop that stops at its first bad key hides the later keys an enclosing intersection's other operand does not own — and the intersection then agrees to reject nothing, reporting `true` for input the default rejects. Records therefore take no guard at all, with a comment saying why. Adding one back produces seven fuzz divergences, all of them intersections over records.
C
Colin McDonnell committed
764ac59f1afc1a46e7ecc2d5e8e86c57b7d66164
Parent: 07917f4
Committed by GitHub <noreply@github.com> on 9/1/2026, 10:04:36 PM