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