Skip to content

Preserve callsites in parse stack traces#5910

Open
colinhacks wants to merge 1 commit intomainfrom
cm/stack-trace-callsite
Open

Preserve callsites in parse stack traces#5910
colinhacks wants to merge 1 commit intomainfrom
cm/stack-trace-callsite

Conversation

@colinhacks
Copy link
Copy Markdown
Owner

@colinhacks colinhacks commented Apr 30, 2026

Closes #3254.

This keeps Zod's existing best-effort Error.captureStackTrace behavior, but applies it consistently to the public throwing parse paths. Top-level z.parse / z.parseAsync now trim their own core parse frame, and encode / decode sync and async paths pass the same callsite boundary that instance .parse() already used.

I also added regression coverage for instance, detached, and top-level parse plus codec methods so the first stack frame stays at the user callsite instead of core/parse.ts or classic/schemas.ts.

Nested stack probes were run from a temporary fixture at /tmp/zod-stack-probe/scenario.ts to simulate validation/serialization inside app call chains.

Before this change, schema.parse() already started at the user validation function, but top-level parse and codec paths exposed Zod internals first:

=== instance .parse() nested env validation ===
    at validateEnvWithInstanceParse (/private/tmp/zod-stack-probe/scenario.ts:15:20)
    at initializeConfigLayer (/private/tmp/zod-stack-probe/scenario.ts:11:10)
    at loadConfigFromBoot (/private/tmp/zod-stack-probe/scenario.ts:7:10)

=== top-level z.parse() nested request validation ===
    at Module.<anonymous> (.../packages/zod/src/v4/core/parse.ts:24:10)
    at validatePayloadWithTopLevelParse (/private/tmp/zod-stack-probe/scenario.ts:30:12)
    at routeRequest (/private/tmp/zod-stack-probe/scenario.ts:26:10)
    at handleIncomingRequest (/private/tmp/zod-stack-probe/scenario.ts:22:10)

=== instance .encode() nested response serialization ===
    at <anonymous> (.../packages/zod/src/v4/core/parse.ts:24:10)
    at Module.<anonymous> (.../packages/zod/src/v4/core/parse.ts:105:21)
    at _.inst.encode (.../packages/zod/src/v4/classic/schemas.ts:240:41)
    at encodeUserIdWithInstanceMethod (/private/tmp/zod-stack-probe/scenario.ts:47:22)
    at serializeResponse (/private/tmp/zod-stack-probe/scenario.ts:43:10)
    at sendResponse (/private/tmp/zod-stack-probe/scenario.ts:39:10)

=== top-level z.parseAsync() nested request validation ===
    at Module.<anonymous> (.../packages/zod/src/v4/core/parse.ts:45:10)
    at validatePayloadWithTopLevelParseAsync (/private/tmp/zod-stack-probe/scenario.ts:71:12)
    at routeRequestAsync (/private/tmp/zod-stack-probe/scenario.ts:67:10)
    at handleIncomingRequestAsync (/private/tmp/zod-stack-probe/scenario.ts:63:10)

After this change, those same nested scenarios start at the user's validation/serialization function and preserve the surrounding app frames:

=== top-level z.parse() nested request validation ===
    at validatePayloadWithTopLevelParse (/private/tmp/zod-stack-probe/scenario.ts:30:12)
    at routeRequest (/private/tmp/zod-stack-probe/scenario.ts:26:10)
    at handleIncomingRequest (/private/tmp/zod-stack-probe/scenario.ts:22:10)

=== instance .encode() nested response serialization ===
    at encodeUserIdWithInstanceMethod (/private/tmp/zod-stack-probe/scenario.ts:47:22)
    at serializeResponse (/private/tmp/zod-stack-probe/scenario.ts:43:10)
    at sendResponse (/private/tmp/zod-stack-probe/scenario.ts:39:10)

=== top-level z.encode() nested event serialization ===
    at encodeUserIdWithTopLevelFunction (/private/tmp/zod-stack-probe/scenario.ts:59:12)
    at serializeEvent (/private/tmp/zod-stack-probe/scenario.ts:55:10)
    at publishEvent (/private/tmp/zod-stack-probe/scenario.ts:51:10)

=== top-level z.parseAsync() nested request validation ===
    at validatePayloadWithTopLevelParseAsync (/private/tmp/zod-stack-probe/scenario.ts:71:12)
    at routeRequestAsync (/private/tmp/zod-stack-probe/scenario.ts:67:10)
    at handleIncomingRequestAsync (/private/tmp/zod-stack-probe/scenario.ts:63:10)

=== instance .encodeAsync() nested response serialization ===
    at encodeUserIdWithInstanceAsyncMethod (/private/tmp/zod-stack-probe/scenario.ts:88:22)
    at serializeResponseAsync (/private/tmp/zod-stack-probe/scenario.ts:84:10)
    at sendResponseAsync (/private/tmp/zod-stack-probe/scenario.ts:80:10)

=== top-level z.encodeAsync() nested event serialization ===
    at encodeUserIdWithTopLevelAsyncFunction (/private/tmp/zod-stack-probe/scenario.ts:100:12)
    at serializeEventAsync (/private/tmp/zod-stack-probe/scenario.ts:96:10)
    at publishEventAsync (/private/tmp/zod-stack-probe/scenario.ts:92:10)

Validated with pnpm vitest run packages/zod/src/v4/classic/tests/error.test.ts, pnpm vitest run packages/zod/src/v4, pnpm test, and pnpm build. The push hook also reran the full pnpm test suite successfully.

Moltar was run against unchanged main and this branch on Node v24.14.0 / Apple M1 Max. The clean baseline run reported zod4 at 179 us/iter for z.object() safeParse; the clean branch run reported 184 us/iter. Repeat runs were noisy on both trees, including an unchanged-main repeat at 474 us/iter, so I don't see a meaningful regression signal here. This patch does not change the safeParse implementation that Moltar exercises.

I also ran an inline ad hoc benchmark, without writing a benchmark file, against the paths this PR actually changes. The initial short run was too noisy to trust, so I reran the failure cases with 8 rounds of 50k iterations and compared medians:

main:
instance parse fail:    10814.7 ns/iter
top-level parse fail:  11289.8 ns/iter
instance encode fail:  11490.9 ns/iter
top-level encode fail: 11038.8 ns/iter

this branch:
instance parse fail:    10519.6 ns/iter
top-level parse fail:  10275.8 ns/iter
instance encode fail:  11008.3 ns/iter
top-level encode fail: 10617.6 ns/iter

The longer success-path run was also in the same range for the paths measured. The only suspicious result was one branch run of top-level parse fail at 16971 ns/iter, but the repeated 8-round run did not reproduce it. I don't see a consistent regression signal from either Moltar or the targeted failure-path benchmark.

@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented Apr 30, 2026

Reviewed PR #5910 — clean implementation that extends captureStackTrace callsite preservation to codec functions (encode, decode, encodeAsync, decodeAsync). The refactoring of _parse/_parseAsync to named function expressions and the finalizeParams helper are well-structured. Tests pass and cover all call patterns (bound, detached, standalone). No issues found.

Task list (3/3 completed)
  • Checkout PR and read the diff
  • Investigate major areas of change
  • Self-critique and submit review

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented Apr 30, 2026

TL;DR — When a Zod parse/encode/decode call throws, the first stack frame now points to the caller's code rather than Zod internals. This is achieved by giving each parse function a stable reference that Error.captureStackTrace can use as the constructorOpt boundary, and by threading a callee parameter through the codec functions.

Key changes

  • Stable function references for captureStackTrace_parse, _parseAsync, and all codec factories now return a named fn variable so captureStackTrace(e, fn) strips Zod frames up to the call boundary.
  • callee forwarding for encode/decode — Codec methods (encode, decode, encodeAsync, decodeAsync) accept an optional _params bag and pass { callee: inst.<method> } from ZodType, mirroring how parse/parseAsync already worked.
  • finalizeParams helper — Consolidates the logic for resolving which callee and error class to use, preferring an explicit _params.callee over the default fn.
  • Stack-frame assertions in tests — New expectFirstStackFrameAtCallsite helper verifies the first at frame references the test file, not core/parse.ts or classic/schemas.ts.

Summary | 4 files | 1 commit | base: maincm/stack-trace-callsite


Stable callsite preservation via named function references

Before: captureStackTrace received an anonymous arrow from the factory's return expression — or nothing at all for codec methods — so the top stack frame pointed into core/parse.ts.
After: Each factory assigns its inner function to a local const fn, which becomes the constructorOpt argument. The first visible frame in the thrown error is the user's callsite.

The key insight is that Error.captureStackTrace(err, fn) removes all frames at or above fn from the stack. By making the parse implementation itself the boundary function (rather than requiring the caller to pass one), every path — schema.parse(...), z.parse(schema, ...), or a destructured const { parse } = schema — gets a clean stack trace by default. When an explicit callee is supplied (e.g. from ZodType instance methods), it takes precedence via the new finalizeParams helper.

Why do codec methods need the extra _params argument? Unlike parse/parseAsync which already accepted _params, the codec functions (encode, decode, etc.) previously had no way to pass a callee through to the underlying parse call. Adding the optional _params bag lets ZodType instance methods forward { callee: inst.encode } so the stack trace omits both the codec wrapper and the inner parse function.

core/parse.ts · classic/schemas.ts · classic/parse.ts · error.test.ts

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed — no issues found.

Task list (3/3 completed)
  • Checkout PR and read the diff
  • Investigate major areas of change
  • Self-critique and submit review

Pullfrog  | View workflow run | Using Claude Opus𝕏

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.

Better understandable errors

1 participant