SIGN IN SIGN UP

perf(v4): build the safeParse error on first read (#6519)

* perf(v4): build the safeParse error on first read

A failing safeParse spent ~85% of its time finalizing issues and constructing the ZodError, which a caller that only branches on success never reads. The result keeps error as an own enumerable property, backed by one shared accessor descriptor and a hidden state slot, so keys, spreads, clones and toEqual see the same shape and the .error load sites stay monomorphic.

Measured against main (Node 26.7, Apple Silicon, load < 6, best of 4 interleaved rounds): failing safeParse with only .success read 1053 -> 274 ns; with .error.issues read 1060 -> 1257 ns; valid parses unchanged. Retained per failing result 785 -> 640 B unread, 873 B read. Bundles grow 117 B gzipped on every fixture; the mini ceilings are raised accordingly.

Error maps now run on the first read of error instead of at parse time; the nine tests in issue-schema.test.ts that pin the old timing fail on purpose and are left as they are for review.

* perf(v4): finalize Standard Schema issues off the raw payload

~standard.validate read result.error right away, which under the lazy result paid the accessor for nothing. It now finalizes the issues from the raw parse payload and never builds a result object, so it stays at its #6509 speed.

* fix: keep the async rerun when a sync Standard Schema validate meets a promise

* test: read the error where a test only observes the error maps

Error maps run on the first read of result.error now, so the issue-schema tests that watched them through a bare safeParse read the error explicitly.

* perf(v4): keep the lazy result a plain getter

The shared accessor descriptor and hidden state slot read ~15% faster than a getter in the literal, but cost ~75 B gzipped in every bundle. At mini's scale the bytes matter more, so the result is the five-line form; the ceilings come down to the measured sizes plus headroom.

* fix: reject instead of throwing when a sync check fails under ~standard

The async fallback ran outside any promise boundary, so a synchronously throwing check escaped validate() as a throw where main returned a rejection. The fallback is an async function now, with a test pinning the rejection; also drops the stale shared-descriptor comment lines from the bundle ceilings.

* docs: refresh the ceiling notes for the async wrapper's ten bytes
C
Colin McDonnell committed
bec73bea5a8ed19daec15655598fe5501bb797e7
Parent: 07c43e2
Committed by GitHub <noreply@github.com> on 8/31/2026, 8:03:23 PM