perf(v4): skip the eager stack capture when building a ZodError - #6450
Conversation
02f5abc to
94c9b24
Compare
There was a problem hiding this comment.
ℹ️ No blocking issues. The mechanism holds up under everything I could throw at it — two notes inline, one about an edge case the PR body claims to cover and one about a new test that can't currently fail.
Reviewed changes — first pullfrog review on this PR (the only prior review was a Copilot quota notice), so this covers both commits rather than a delta.
- Suppressed V8's stack capture during error construction —
newError()incore/core.tszeroesError.stackTraceLimitaroundnew Definition()and restores it in afinally, taking effect only where$constructorwas given aParent. - Gated the trick on a writability probe —
_Eresolves once at module load toErrorornull, so non-V8 engines and realms that already hardenedErrorfall back to a plainnew. - Pinned the error-construction contract — new classic test asserts
instanceof Error,types.isNativeError,Object.prototype.toString,structuredClone, the frameless returned stack, and that the borrowed global is given back. - Added throwing-path coverage for mini and the AOT fallback — both assert the reinstalled stack is rooted at the parse call site.
- Raised the three
zod/minibundle ceilings to 2906 / 3372 / 4390.
What I verified myself rather than taking on trust:
- Only two
Parent:sites exist in the tree (classic/errors.ts:78,core/errors.ts:296), bothParent: Error, so no schema construction goes through the zeroed window. class Definition extends Error {}has no constructor body, sonew Definition()reaches only theErrorintrinsic — there is no reentrancy window in which user code could observe the zeroed limit.init()and its lazymessageaccessor run after the restore.- The
stackproperty's descriptor is byte-for-byte identical between a limit-0 and a normal construction, before and after first read. Worth checking given how much this repo treats descriptors as contract. - The ceilings are honest: re-measuring the three fixtures here gives 2878 / 3344 / 4362, exactly the numbers in the comment, each with the 28 bytes of headroom the file calls for.
node --frozen-intrinsicstakes the fallback correctly, and the fullzodproject is green (374 files, 4924 tests, no type errors).
ℹ️ Nitpicks
_E: anygives up the typing the previous revision had (ErrorConstructor & { stackTraceLimit: number }). Since_Eis a module-levelconst,(ErrorConstructor & { stackTraceLimit: number }) | nullnarrows fine after theif (!_E)guard and costs nothing at runtime.- The rewritten ceiling comment drops the closing sentence that explained the per-fixture notes underneath it. Without it, the
Also carries …comments on the next two fixtures read as unanchored, and the2836 / 3299 / 4329triple sitting directly above"zod-mini-boolean"invites being misread as belonging to that one fixture.
Claude Opus | 𝕏
There was a problem hiding this comment.
Important
Re-review at the same head as the previous pullfrog review — no new commits, so no new inline comments (its three threads still stand). Two things this run adds: ecosystem precedent that upgrades the open core.ts:22 thread from "narrow edge case" to "the one variant everyone else abandoned", and a trade-off nobody has raised yet.
Reviewed changes — the same two commits at 94c9b24, re-derived independently rather than as a delta.
- Stack capture suppressed during error construction —
newError()atcore/core.ts:26zeroesError.stackTraceLimitaroundnew Definition()and restores it in afinally, reached only where$constructorwas given aParent. - Gated on a load-time writability probe —
_Eresolves once toErrorornull, falling back to a plainnew. - Error-construction contract pinned — the new classic test covers
instanceof Error,types.isNativeError,Object.prototype.toString,structuredClone, the frameless returned stack, and restoration of the borrowed global. - Throwing-path coverage added for
zod/miniand the AOT compile fallback. - Three
zod/miniceilings raised to 2906 / 3372 / 4390, i.e. +70 / +73 / +62 B gzipped.
Confirmed independently this run: the PR body's claim that a thrown error is unaffected holds. Every $ZodRealError throw site routes through core/parse.ts:31 or :55 — including the AOT fast-path fallback in core/compile.ts, which delegates to the schema's own .parse with a callee override. $ZodAsyncError, $ZodEncodeError and $ZodCyclicError have no Parent: Error and never reach newError. Touched test files are green (82 tests, no type errors).
⚠️ Node adopted this exact trick, then had to move the writability check to each write
The open thread on core.ts:22 reads as a judgement call. The precedent makes it lopsided: nodejs/node#24747 — the citation in the PR body — is where Node adopted save-zero-restore in v11.5.0, and nodejs/node#38215 is where Node had to add a writability guard after --frozen-intrinsics broke core with the identical Cannot assign to read only property 'stackTraceLimit'. Vitest, this repo's own runner, calls isErrorStackTraceLimitWritable() before every write. Probing once at module load is the variant both moved away from, and ESM guarantees zod evaluates before an app-entry lockdown().
Technical details
# Load-time probe is the one form the precedent rejects
## Affected sites
- `packages/zod/src/v4/core/core.ts:22` — `_E` snapshots writability at module evaluation; nothing re-checks.
- `packages/zod/src/v4/core/core.ts:29`, `:33` — both assignments throw in strict mode if `Error` hardened after that snapshot. The throw escapes `newError`, so every failed parse throws instead of returning an error.
## Evidence gathered this run
- Reproduced on this branch (`pnpm dev`, node v24): import zod, `Object.freeze(Error)`, `z.string().safeParse(1)` →
`TypeError: Cannot assign to read only property 'stackTraceLimit' of function 'function Error() { [native code] }'`.
- SES `lockdown()` with the default `errorTaming: "safe"` *deletes* `Error.stackTraceLimit` and then hardens `Error`, so the failure there is
`TypeError: Cannot add property stackTraceLimit, object is not extensible` — the `!_E` fallback does not catch that shape either.
`endojs/endo#8` documents this as a known pattern that breaks libraries.
- `nodejs/node#24747` (v11.5.0) introduced save-zero-restore in Node core; `nodejs/node#38215` later wrapped it in a writability check
because `--frozen-intrinsics` surfaced the same `TypeError` from core libs.
- `vitest/dist/chunks/index.*.js:380` — `if (isErrorStackTraceLimitWritable()) Error.stackTraceLimit = 0;`, checked per write.
## Required outcome
A realm that hardens `Error` after zod's module graph evaluates must not turn every failed parse into a `TypeError`.
Alternatively, if that is judged out of scope, the PR body should stop claiming hardened realms are covered — the guard
only holds when the hardening precedes the import.
## Suggested approach (optional)
- Latch `_E` to `null` on the first failed assignment, so a post-`lockdown()` app degrades to today's behavior rather than crashing.
The write is already inside a function that has a `try`; the cost is one `catch`.
- Re-checking the descriptor per call is what Node and vitest do, but that is a `getOwnPropertyDescriptor` on the failed-parse
hot path — worth measuring against the latch before choosing, given the three axes.
## Open questions for the human
- Is SES `lockdown()` after import in scope at all? It is the only realistic route here outside a deliberate `Object.freeze(Error)`.ℹ️ A re-thrown safeParse error now has zero frames, which is more than a console-rendering change
The PR body describes the visible effect as Node printing [ZodError: …]. Measured on this branch, the sharper statement is that if (!r.success) throw r.error — a very common pattern — produces a thrown error whose stack has 0 at frames, against 4 for the equivalent parse. throw does not re-capture, so nothing downstream can recover them, and error trackers fall back to grouping by message. That is a defensible price for 3–4x, but it is a product decision rather than a rendering detail, and the test it replaces (stack trace carries the message and the parse call site) was pinning exactly this.
Technical details
# Frames are unrecoverable on the re-throw path
## Affected sites
- `packages/zod/src/v4/core/parse.ts:81`, `:101` — `_safeParse` / `_safeParseAsync` construct the error and never call `util.captureStackTrace`, which is correct for the optimization but means the returned object's `stack` is final.
- `packages/zod/src/v4/classic/tests/error.test.ts:875` — the replaced test asserted the returned error's stack reached the call site.
## Measurement
```
re-thrown safeParse error frame count: 0
header: ZodError: [
thrown-by-parse frame count: 4
```
## Required outcome
Nothing in the code necessarily — but the PR body should describe the effect as "an error returned by `safeParse` has no stack frames,
including if you re-throw it" rather than as a Node inspection quirk, since that is what users will hit.
## Open questions for the human
- Is `throw result.error` common enough in the ecosystem to be worth a note in the release notes or the error-handling docs?Claude Opus | 𝕏
94c9b24 to
3db9990
Compare
3db9990 to
5fb21ad
Compare
There was a problem hiding this comment.
ℹ️ The latch fixes the crash — I reproduced the old failure and confirmed it's gone. One note inline: neither of the two new safety branches is actually covered by the test added for them.
Reviewed changes — delta from 94c9b24 to 3db9990. Both commits were rewritten by the force-push, so this is scoped by range-diff rather than SHA.
- Replaced the load-time writability probe with a runtime latch —
_Enow starts asErrorand is set tonullthe first timeError.stackTraceLimit = 0throws, so a realm that hardensErrorafter zod's import graph evaluates degrades instead of crashing. - Added a non-numeric bail-out —
typeof saved === "number"skips the trick entirely when the property is absent, which is both the non-V8 case and the shape SESlockdown()leaves behind (it deletes the property before hardening). - Made the two throwing-path tests able to fail —
compile.test.tsandmini/error.test.tsnow capturethrownoutside thetryinstead of relying onexpect.hasAssertions(). - Dropped the hardcoded
10from the restore assertion in favour of the ambientError.stackTraceLimit. - Retuned the three
zod/miniceilings to 2900 / 3364 / 4390.
Confirmed by running it rather than reasoning about it:
- The old crash is genuinely fixed. Post-import
Object.freeze(Error)no longer throws out ofsafeParse— it latches, degrades to a normal framed error, stays degraded, and leavesstackTraceLimituntouched. The SES delete-then-harden shape takes the new non-numeric path with no throw. - The ceiling comment's arithmetic is exact. Reverting
core.tstomainand rebuilding measures 2808 / 3271 / 4300 against 2872 / 3336 / 4362 on this branch —+64 / +65 / +62, and each ceiling is measured + 28. (The stale4301in the comment being replaced is why the ceiling delta reads as 61; the measured delta is 62.) - Removing the frameless-stack test didn't cost thrown-path coverage —
error.test.ts:754,:768and:792still pin the reinstalled frames acrossparse, the codec entry points, and post-suspension async. - The latch being permanent is the right call, not a gap: under
lockdown()the hardening is irreversible, so re-probing per call would only burn cycles. - Touched suites green — 567 tests across the three files in both the
zodandcompile-modeprojects, no type errors,packages/treeshakegreen.
ℹ️ Nitpicks
mini/tests/error.test.ts:35dereferences(thrown as Error).stack!without first assertingthrownis anError. Itscompile.test.tssibling — changed in the same commit for the same reason — does assert it, so a regression that stops the parse throwing fails cleanly there and withCannot read properties of undefinedhere.mini/tests/error.test.ts:16-21still keeps both its assertions inside an unguardedcatch, which is the exact shape this commit just fixed three lines below it.
Claude Opus | 𝕏
5fb21ad to
fa33550
Compare
There was a problem hiding this comment.
ℹ️ The coverage gap is closed — I mutation-tested both bail-outs and each one now fails a test when removed. Only a comment-style nit left inline.
Reviewed changes — delta from 3db9990 to fa33550. Test-only; core/core.ts and packages/treeshake/bundle-size.test.ts are byte-identical to the previously reviewed revision.
- Split the single bail-out test into one per branch — the old
"an engine without a writable stackTraceLimit still builds a working error"installed{ value: undefined, writable: true }and exercised neither branch it was named for. - Added a fresh-module test for the latch —
vi.resetModules()plusawait import("zod/v4"), thenError.stackTraceLimitredefined non-writable, so the latch fires in a throwaway instance. - Added an accessor test for the non-numeric bail-out — a
setthat increments a counter makes "never written to" observable, which a data property could not. - Hoisted
e2out of thecatchinzod/mini's"error inheritance", so its two assertions run unconditionally. - Added
expect(thrown).toBeInstanceOf(Error)before the.stack!deref inzod/mini's thrown-stack test.
Confirmed by running it rather than reasoning about it:
- Both bail-outs are genuinely covered now. Replacing
typeof saved === "number"withtruefails"an engine with no stackTraceLimit is never written to"; removing thetry/catcharound the write fails"a realm that hardens Error after import degrades instead of throwing"withTypeError: Cannot assign to read only property 'stackTraceLimit'. The PR body's "a test covers it" holds. vi.resetModules()really does isolate. I appended a probe after the hardening test asserting the file's own statically-importedzstill returns a frameless stack, ran the whole file, and it passed — so the latch stays in the throwaway instance rather than poisoning whatever runs after it. Both vitest configs setisolate: true, so it can't leak across files either.- Green at this head: 86 tests across the two error files under
--project zodwith no type errors, and 190 undercompile-modeincludingcore/tests/compile.test.ts.pnpm check:commentsclean.
One thing I deliberately did not flag: _E = null itself is uncovered — removing only that line, keeping the catch, changes nothing observable, it just re-reads and re-throws on every later failed parse. That's cost, not behavior, and not worth a test.
ℹ️ Nitpicks
- The two-element loop in the hardening test reads as if it pins the latch's permanence, but it can't distinguish latching from catching twice. Harmless as written.
Claude Opus | 𝕏
fa33550 to
82ac047
Compare
82ac047 to
7b8f2fe
Compare
There was a problem hiding this comment.
ℹ️ The new bail-out is load-bearing and correctly covered — I checked the engine claim against MDN's compat data and mutation-tested the guard. Three small inline corrections, one of them a version number that's off by a major.
Reviewed changes — delta from 82ac047 to 7b8f2fe. This is the first substantive change since 3db9990; the intervening revisions were test and comment prose.
- Added a third bail-out gated on
captureStackTrace—_Eatcore.ts:22now initializes to"captureStackTrace" in Error ? Error : null, so an engine that hasstackTraceLimitbut cannot reinstate frames opts out of the trick entirely rather than permanently losing the stack on a thrown error. - Expanded the
core.ts:21comment to cover both the new condition and the existing latch. - Added a test for the new bail-out —
vi.resetModules(),delete Error.captureStackTrace, freshawait import("zod/v4"), then assert a thrown error's stack still reaches the parse call site.
Confirmed by running it rather than reasoning about it:
- The guard is not dead code, which was my main question given that
typeof saved === "number"already covers engines withoutstackTraceLimit. MDN'sbrowser-compat-dataputs Safari'sError.stackTraceLimitat 11.1 andError.captureStackTraceat 17.2, so there is a real ~5.5-year window of JSC versions where the existing bail-out passes and frames still can't be put back. The new condition is the only thing covering it. - It's genuinely covered. Reverting
:22to= Errorfails the new test. util.ts:396already runs the identical probe and falls back to a no-op, so the two load-time checks agree. DeletingcaptureStackTraceafter import doesn't break the restore either — util snapshots the function reference, not its presence.- Green at this head: 88 tests across both error files under
--project zodwith no type errors, 191 undercompile-modeincludingcore/tests/compile.test.ts.pnpm check:commentsclean.
ℹ️ The PR body documents two bail-outs, but there are now three
The body walks through the hardened-realm latch and the missing-stackTraceLimit fallback, and this commit adds a third condition that isn't mentioned. It's the one with the widest real-world reach of the three — every Safari before 17.2 rather than a deliberately hardened realm — and it's also the one that turns "this is a V8 and JSC optimization" into "this is a V8 optimization". Worth a sentence, given how carefully the rest of the body records the mechanism.
Technical details
# PR body omits the `captureStackTrace` bail-out
## Affected sites
- PR description — the paragraph beginning "A realm can harden `Error` at any point" enumerates the latch and the non-numeric fallback, and stops there.
## Required outcome
- The body should name the third condition and why it exists: suppressing the capture is only recoverable where `parse()` can reinstate frames, so an engine with `stackTraceLimit` but no `captureStackTrace` opts out.
- The "Refs" line and the benchmark table both read as V8-only already; a reader comparing against the guard shape shouldn't have to infer the third branch from the source.Claude Opus | 𝕏
Constructing a ZodError runs the Error constructor, and V8 captures a structured stack trace there. That capture is the dominant cost of a failed parse and roughly half the error's retained bytes, and it grows with the depth of the call stack. Suppress it by zeroing Error.stackTraceLimit around the construction and restoring it afterwards. The instance stays a genuine Error, so instanceof, Error.isError, util.types.isNativeError and [object Error] all keep working; what it gives up is the frames on an error returned by safeParse. parse() puts real frames back, since it already calls util.captureStackTrace with the caller on the way out. The limit is a global static on Error with no per-subclass equivalent, and temporarily zeroing it is the technique MDN documents and Node core uses internally. Setting it on the subclass is ignored, and Error.prepareStackTrace suppresses the frames without avoiding their collection, so neither is an option. A realm can harden Error at any point, including after this module is evaluated, so the assignment is guarded and latches off permanently the first time it fails. Engines with no stackTraceLimit at all take the same fallback.
7b8f2fe to
241ee64
Compare
There was a problem hiding this comment.
ℹ️ The re-measured ceilings are exact — I rebuilt both sides and got the recorded numbers to the byte. One leftover from the rebase inline.
Reviewed changes — delta from 7b8f2fe to 241ee64. The branch was rebased, so most of the range is base drift from #6448 and #6452; core/core.ts and all three test files are byte-identical to the previously reviewed revision.
- Rebased onto
d85472c4, which moves thezod-mini-objectbaseline since declared symbol keys land in every bundle containingz.object. - Re-measured and raised the three
zod/miniceilings to 2903 / 3366 / 4437, and rewrote the record above them.
Confirmed by measuring rather than trusting the comment: reverting core/core.ts to d85472c4 and rebuilding gives 2807 / 3270 / 4346 against 2875 / 3338 / 4409 on this branch — +68 / +68 / +63, exactly what the comment now claims, with 28 bytes of headroom on each. That also explains why the previous run's +67 / +68 / +65 read as off by one: main's own boolean and string measurements drifted down a byte with the rebase. packages/treeshake is green, as are 88 tests across both error files under --project zod with no type errors and 147 under compile-mode.
Claude Opus | 𝕏
241ee64 to
171dcb6
Compare
171dcb6 to
9b1c93d
Compare
There was a problem hiding this comment.
ℹ️ Both threads from the last review are genuinely closed — I re-ran the mutation and the failure is clean now. Two nits inline on the same test, neither of them from this commit's lines.
Reviewed changes — delta from 241ee64 to 171dcb6. git diff-tree -r --stat is a single file, +5 / -3; core/core.ts, bundle-size.test.ts, core/tests/compile.test.ts and mini/tests/error.test.ts are byte-identical to the previously reviewed revision, so the ceiling measurements verified there still stand.
- Corrected the Safari window in the
captureStackTracebail-out comment — "before 18.2" → "11.1 through 17.1", which matches MDN's compat data (stackTraceLimit11.1,captureStackTrace17.2) and bounds the range on both ends rather than just the top. - Moved the thrown-error assertions below the
finally—thrownis hoisted, andexpect(thrown).toBeInstanceOf(Error)now guards the.stackderef the way its two siblings already did.
Confirmed by running it rather than reasoning about it:
- The assertion move does what it was supposed to. Reverting the
core.ts:22gate to= Errornow fails withexpected 'ZodError: […' to contain 'callSite'at:925, against theTypeError: Error.captureStackTrace is not a functionit produced before. The test can still fail, and it fails legibly. vi.resetModules()doesn't need undoing here. It only invalidates the cache for later dynamicimport(), so it can't reach back into the file's staticzbinding; the throwaway instance's module-level_Eis a separate variable from the one the rest of the file uses.- Green at this head: 88 tests across both error files under
--project zodwith no type errors, 147 undercompile-mode,pnpm check:commentsclean.pnpm lint:checkexits 0 — the suppression warning below is a warning, not a CI failure.
Claude Opus | 𝕏
…stack Turns the two commented-out `instanceof Error` assertions into real ones, and adds a test that a returned error is still a native Error with a frameless stack, and that the borrowed Error.stackTraceLimit is given back unchanged. Adds a test for the fallback branch taken where the limit is not assignable, which is the whole safety story off V8. Adds compiled-fallback parity for z.compile, and raises the three zod/mini bundle ceilings, measured.
9b1c93d to
1015e4b
Compare
There was a problem hiding this comment.
✅ No new issues found. Both nits from the last review are closed, and the descriptor rework doesn't cost the test its ability to fail.
Reviewed changes — delta from 9b1c93d to 1015e4b. git diff-tree -r --stat is a single file, +2 / -3; core/core.ts, bundle-size.test.ts, core/tests/compile.test.ts and mini/tests/error.test.ts are byte-identical to the previously reviewed revision, so the ceiling measurements verified at 241ee64 still stand.
- Restored
Error.captureStackTracethrough a captured descriptor —Object.getOwnPropertyDescriptorat:904andObject.definePropertyin thefinallyat:920, replacing the bare re-assignment. - Dropped the dead
biome-ignoresuppression above thedelete—performance/noDeleteno longer exists in Biome 2.
Confirmed by running it rather than reasoning about it:
- The descriptor round-trip does what it was supposed to. Measured both shapes directly: the old bare assignment recreated the property with
enumerable: trueand leftObject.keys(Error)as['stackTraceLimit', 'captureStackTrace']; the newdefinePropertygives backenumerable: falseand['stackTraceLimit']. The test now matches its two siblings below it, which already restoredstackTraceLimitthis way. - The rework didn't cost the test its teeth. Reverting the
core.ts:22gate to= Errorstill fails at:924withexpected 'ZodError: […' to contain 'callSite'— the failure comes from the assertion, not from aTypeErrorthrown out of the restore. - The suppression is genuinely gone, not just moved.
biome linton this file is now silent; it was the only warning here. - The
zod-mini-objectnote reads correctly against its ceiling now. Re-checked the arithmetic in place: measured + 28 gives 2903 / 3366 / 4437 on the nose, and the trimmed#6448line no longer carries a superseded measurement. - Green at this head: 88 tests across both error files under
--project zodwith no type errors,pnpm check:commentsclean.
Every inline thread on this PR is now resolved.
Claude Opus | 𝕏

Constructing a ZodError runs the
Errorconstructor, and V8 captures a structured stack trace there. That capture is the dominant cost of a failed parse and roughly half the error's retained bytes, and it grows with the depth of the call stack. ZeroingError.stackTraceLimitaround the construction skips it.The error stays a genuine
Error. Checks withinstanceof,Error.isError,util.types.isNativeError,Object.prototype.toStringandstructuredCloneall behave exactly as they do today. What it gives up is the frames on an error returned bysafeParse— the message header is still there.The throwing path keeps its full stack, and gets faster too. Today it captures twice: once in the
Errorconstructor and again incaptureStackTrace, which trims to the caller. Skipping the first leaves one capture and the same frames.safeParseparseAcross a wider set of failing
safeParseshapes —z.string(), a 5-key object with one and with all keys bad, a 3-deep nested object, an array of 20, a discriminated union with no match — the range is 2.9x to 3.7x, against a control on valid input of 1.05x–1.09x. Retained bytes per returned error drop from 1073 B to 714 B, and from 1442 B to 708 B at call depth 40, so the cost no longer grows with stack depth. Gzipped bundle cost is +63 to +68 B, since every bundle carriescore.ts.Three alternatives do not work. Setting
stackTraceLimiton the subclass is ignored, because V8 reads it only offError— MDN is explicit that it is a static property with no per-subclass form. AssigningError.prepareStackTracesuppresses the frames but leaves construction just as slow, so the eager cost is collecting them rather than formatting them. Droppingextends Erroraltogether measures the same as skipping only the constructor, so it would forfeitinstanceof Errorfor nothing. Save, zero, construct, restore is what MDN documents and what Node core does internally (nodejs/node#24747).Two engine cases fall back to a plain construction rather than the trick. Where
Error.captureStackTraceis missing there is no way to put the frames back, so suppressing would strip the stack from a thrown error — Safari 11.1 through 17.1 hasstackTraceLimitbut notcaptureStackTrace. WherestackTraceLimitis absent or frozen, assigning would throw; SESlockdown()deletes it before hardeningError, and an app that hardensErrorafter import latches the trick off on the first failure. Each fallback fails a test when removed.The cost worth naming explicitly is that a returned error carries no frames at all, so re-throwing one —
if (!r.success) throw r.error— produces a thrown error with an empty trace whereparse()would give five. Nothing downstream can recover them, sincethrowdoes not re-capture, and Node prints such an error as[ZodError: …].Refs #6316, #5910