Skip to content

Commit f101d8c

Browse files
authored
Preserve callsites in parse stack traces (#5910)
* fix(v4): preserve callsites in parse stack traces * fix(v4): await the async parse wrappers so the callee stays on the chain `Error.captureStackTrace(e, callee)` is skip-until-seen: V8 discards frames until it meets `callee`, and if it never meets it, it discards them all. An async function that returns its inner promise without awaiting has already left the async chain by the time the rejection is built, so its frame is not there to be found and `err.stack` comes back as the message line alone. That made all four async codec paths return an empty stack where main returned a usable one, which is the opposite of this branch's purpose. Instance `.parseAsync()` had the same defect on main already; it is fixed here too, since it is the same one-word change and the same invariant. The existing coverage could not catch this. An async function body runs synchronously up to its first `await`, so `z.string()` never suspends and its callee is always still on the chain. The new test uses an async refinement and an async codec, and fails without this fix. * test(treeshake): re-measure the ceilings against the current base The three raises were measured against a base five commits stale. Merging current main puts `zod-mini-object` at exactly its ceiling and the other two at 18 bytes, back in the 16-21 band this file's own comment records as having broken within a day. Re-measured on the merged tree: 2808 / 3128 / 4301, so the ceilings move to 28 bytes of headroom each, which is what the comment claims. * test(treeshake): attribute the ceiling raise to the right cause The comment booked the whole 2813 / 3130 / 4288 raise to the callee threading. Measured against the merge base, this branch costs +13 / +17 / +16; the remainder is drift on main since those numbers were last set, and main alone had already reached 4285 against its own 4288. A file whose job is recording what caused each raise should say which part was which.
1 parent 2a41709 commit f101d8c

6 files changed

Lines changed: 195 additions & 52 deletions

File tree

mm.mjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { build } from "esbuild";
2+
import { gzipSync } from "node:zlib";
3+
import path from "node:path";
4+
for (const f of ["zod-mini-boolean", "zod-mini-string", "zod-mini-object"]) {
5+
const r = await build({ entryPoints: [path.join("packages/treeshake", `${f}.ts`)], bundle: true, minify: true, format: "esm", write: false, logLevel: "silent" });
6+
const n = gzipSync(Buffer.from(r.outputFiles[0].contents), { level: 9 }).length;
7+
console.log(`${f.padEnd(20)} measured=${n} ceiling(+28)=${n + 28}`);
8+
}

packages/treeshake/bundle-size.test.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,12 @@ const BUILT_ENTRY = path.join(__dirname, "node_modules", "zod", "index.js");
3838
* The failure message prints the measured size, so updating is mechanical.
3939
*/
4040
const CEILINGS: Record<string, number> = {
41-
"zod-mini-boolean": 2813,
42-
// Raised from 3130 in the commit that caused it: string length checks now measure code points, so any bundle using `.min`/`.max`/`.length` on a string carries the surrogate scan. Measured 3257; 28 bytes of headroom to match the others. `zod-mini-object` does not move — a bundle with no length check still carries none of it.
43-
"zod-mini-string": 3285,
44-
// Raised from 4257 in the commit that caused it: the construction-time discriminator check writes a WeakMap entry from `$ZodObject`, so every bundle containing `z.object` carries it. Measured 4260; 28 bytes of headroom to match the other two.
45-
"zod-mini-object": 4288,
41+
// Raised from 2813 / 3285 / 4288 by the commit that caused it: threading a `callee` to `Error.captureStackTrace` from every throwing parse entry point costs +13 / +17 / +16, since a bundle that parses at all carries it. Measured 2808 / 3271 / 4301 here; 28 bytes of headroom each. The per-fixture notes below record what each ceiling already carried before this.
42+
"zod-mini-boolean": 2836,
43+
// Also carries the code-point string length scan: `.min`/`.max`/`.length` on a string pulls in the surrogate walk.
44+
"zod-mini-string": 3299,
45+
// Also carries the construction-time discriminator check, which writes a WeakMap entry from `$ZodObject`, so every bundle containing `z.object` pays for it whether or not it builds a discriminated union.
46+
"zod-mini-object": 4329,
4647
};
4748

4849
/**

packages/zod/src/v4/classic/parse.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,25 +36,29 @@ export const safeParseAsync: <T extends core.$ZodType>(
3636
export const encode: <T extends core.$ZodType>(
3737
schema: T,
3838
value: core.output<T>,
39-
_ctx?: core.ParseContext<core.$ZodIssue>
39+
_ctx?: core.ParseContext<core.$ZodIssue>,
40+
_params?: { callee?: core.util.AnyFunc; Err?: core.$ZodErrorClass }
4041
) => core.input<T> = /* @__PURE__ */ core._encode(ZodRealError);
4142

4243
export const decode: <T extends core.$ZodType>(
4344
schema: T,
4445
value: core.input<T>,
45-
_ctx?: core.ParseContext<core.$ZodIssue>
46+
_ctx?: core.ParseContext<core.$ZodIssue>,
47+
_params?: { callee?: core.util.AnyFunc; Err?: core.$ZodErrorClass }
4648
) => core.output<T> = /* @__PURE__ */ core._decode(ZodRealError);
4749

4850
export const encodeAsync: <T extends core.$ZodType>(
4951
schema: T,
5052
value: core.output<T>,
51-
_ctx?: core.ParseContext<core.$ZodIssue>
53+
_ctx?: core.ParseContext<core.$ZodIssue>,
54+
_params?: { callee?: core.util.AnyFunc; Err?: core.$ZodErrorClass }
5255
) => Promise<core.input<T>> = /* @__PURE__ */ core._encodeAsync(ZodRealError);
5356

5457
export const decodeAsync: <T extends core.$ZodType>(
5558
schema: T,
5659
value: core.input<T>,
57-
_ctx?: core.ParseContext<core.$ZodIssue>
60+
_ctx?: core.ParseContext<core.$ZodIssue>,
61+
_params?: { callee?: core.util.AnyFunc; Err?: core.$ZodErrorClass }
5862
) => Promise<core.output<T>> = /* @__PURE__ */ core._decodeAsync(ZodRealError);
5963

6064
export const safeEncode: <T extends core.$ZodType>(

packages/zod/src/v4/classic/schemas.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -314,17 +314,32 @@ function _zodTypeParseProps(): _LazyPropsOf<ZodType> {
314314
return fn;
315315
},
316316
parseAsync: (self) => {
317-
const fn: ZodType["parseAsync"] = async (data, params) => parse.parseAsync(self, data, params, { callee: fn });
317+
const fn: ZodType["parseAsync"] = async (data, params) =>
318+
await parse.parseAsync(self, data, params, { callee: fn });
318319
return fn;
319320
},
320321
safeParse: (self) => (data, params) => parse.safeParse(self, data, params),
321322
safeParseAsync: (self) => async (data, params) => parse.safeParseAsync(self, data, params),
322323
// `spa` is an alias: same function object as `safeParseAsync`, as before.
323324
spa: (self) => self.safeParseAsync,
324-
encode: (self) => (data, params) => parse.encode(self, data, params),
325-
decode: (self) => (data, params) => parse.decode(self, data, params),
326-
encodeAsync: (self) => async (data, params) => parse.encodeAsync(self, data, params),
327-
decodeAsync: (self) => async (data, params) => parse.decodeAsync(self, data, params),
325+
encode: (self) => {
326+
const fn: ZodType["encode"] = (data, params) => parse.encode(self, data, params, { callee: fn });
327+
return fn;
328+
},
329+
decode: (self) => {
330+
const fn: ZodType["decode"] = (data, params) => parse.decode(self, data, params, { callee: fn });
331+
return fn;
332+
},
333+
encodeAsync: (self) => {
334+
const fn: ZodType["encodeAsync"] = async (data, params) =>
335+
await parse.encodeAsync(self, data, params, { callee: fn });
336+
return fn;
337+
},
338+
decodeAsync: (self) => {
339+
const fn: ZodType["decodeAsync"] = async (data, params) =>
340+
await parse.decodeAsync(self, data, params, { callee: fn });
341+
return fn;
342+
},
328343
safeEncode: (self) => (data, params) => parse.safeEncode(self, data, params),
329344
safeDecode: (self) => (data, params) => parse.safeDecode(self, data, params),
330345
safeEncodeAsync: (self) => async (data, params) => parse.safeEncodeAsync(self, data, params),

packages/zod/src/v4/classic/tests/error.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,36 @@ afterEach(() => {
77
z.config({ customError: undefined });
88
});
99

10+
function getThrownError(fn: () => unknown): unknown {
11+
try {
12+
fn();
13+
} catch (error) {
14+
return error;
15+
}
16+
throw new Error("Expected function to throw");
17+
}
18+
19+
async function getRejectedError(fn: () => Promise<unknown>): Promise<unknown> {
20+
try {
21+
await fn();
22+
} catch (error) {
23+
return error;
24+
}
25+
throw new Error("Expected function to reject");
26+
}
27+
28+
function expectFirstStackFrameAtCallsite(error: unknown): void {
29+
expect(error).toBeInstanceOf(Error);
30+
const stack = (error as Error).stack;
31+
expect(stack).toEqual(expect.any(String));
32+
33+
const firstFrame = stack!.split("\n").find((line) => line.trim().startsWith("at "));
34+
expect(firstFrame).toBeDefined();
35+
expect(firstFrame).toContain("error.test.ts");
36+
expect(firstFrame).not.toContain("core/parse.ts");
37+
expect(firstFrame).not.toContain("classic/schemas.ts");
38+
}
39+
1040
test("error creation", () => {
1141
const err1 = new z.ZodError([]);
1242

@@ -721,6 +751,59 @@ test("error inheritance", () => {
721751
}
722752
});
723753

754+
test("parse errors capture the caller stack frame", async () => {
755+
const schema = z.string();
756+
const parse = schema.parse;
757+
const parseAsync = schema.parseAsync;
758+
759+
expectFirstStackFrameAtCallsite(getThrownError(() => schema.parse(123)));
760+
expectFirstStackFrameAtCallsite(getThrownError(() => parse(123)));
761+
expectFirstStackFrameAtCallsite(getThrownError(() => z.parse(schema, 123)));
762+
763+
expectFirstStackFrameAtCallsite(await getRejectedError(() => schema.parseAsync(123)));
764+
expectFirstStackFrameAtCallsite(await getRejectedError(() => parseAsync(123)));
765+
expectFirstStackFrameAtCallsite(await getRejectedError(() => z.parseAsync(schema, 123)));
766+
});
767+
768+
test("codec errors capture the caller stack frame", async () => {
769+
const schema = z.string();
770+
const encode = schema.encode;
771+
const decode = schema.decode;
772+
const encodeAsync = schema.encodeAsync;
773+
const decodeAsync = schema.decodeAsync;
774+
775+
expectFirstStackFrameAtCallsite(getThrownError(() => schema.encode(123 as any)));
776+
expectFirstStackFrameAtCallsite(getThrownError(() => encode(123 as any)));
777+
expectFirstStackFrameAtCallsite(getThrownError(() => z.encode(schema, 123 as any)));
778+
779+
expectFirstStackFrameAtCallsite(getThrownError(() => schema.decode(123 as any)));
780+
expectFirstStackFrameAtCallsite(getThrownError(() => decode(123 as any)));
781+
expectFirstStackFrameAtCallsite(getThrownError(() => z.decode(schema, 123 as any)));
782+
783+
expectFirstStackFrameAtCallsite(await getRejectedError(() => schema.encodeAsync(123 as any)));
784+
expectFirstStackFrameAtCallsite(await getRejectedError(() => encodeAsync(123 as any)));
785+
expectFirstStackFrameAtCallsite(await getRejectedError(() => z.encodeAsync(schema, 123 as any)));
786+
787+
expectFirstStackFrameAtCallsite(await getRejectedError(() => schema.decodeAsync(123 as any)));
788+
expectFirstStackFrameAtCallsite(await getRejectedError(() => decodeAsync(123 as any)));
789+
expectFirstStackFrameAtCallsite(await getRejectedError(() => z.decodeAsync(schema, 123 as any)));
790+
});
791+
792+
test("async errors that fail after suspending capture the caller stack frame", async () => {
793+
// `Error.captureStackTrace` is skip-until-seen: when it cannot find its callee it discards every frame, not just the ones above. An async wrapper that returns its inner promise without awaiting has already left the async chain by the time the rejection is built, so its callee is unfindable and the stack comes back empty. Only a schema that actually suspends reaches that path — the sync cases above run straight through to their first await and pass either way.
794+
const refined = z.string().refine(async () => false, "nope");
795+
const codec = z.codec(z.string(), z.number(), {
796+
decode: async (value) => value as never,
797+
encode: async (value) => value as never,
798+
});
799+
800+
expectFirstStackFrameAtCallsite(await getRejectedError(() => refined.parseAsync("x")));
801+
expectFirstStackFrameAtCallsite(await getRejectedError(() => codec.decodeAsync("x")));
802+
expectFirstStackFrameAtCallsite(await getRejectedError(() => z.decodeAsync(codec, "x")));
803+
expectFirstStackFrameAtCallsite(await getRejectedError(() => codec.encodeAsync(5)));
804+
expectFirstStackFrameAtCallsite(await getRejectedError(() => z.encodeAsync(codec, 5)));
805+
});
806+
724807
test("error serialization", () => {
725808
try {
726809
z.string().parse(123);

packages/zod/src/v4/core/parse.ts

Lines changed: 70 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,36 @@ import type * as schemas from "./schemas.js";
44
import * as util from "./util.js";
55

66
export type $ZodErrorClass = { new (issues: errors.$ZodIssue[]): errors.$ZodError };
7+
type $ParseParams = { callee?: util.AnyFunc; Err?: $ZodErrorClass | undefined };
8+
9+
// Always both keys, so the `_params` read site in `_parse` sees one object shape rather than two.
10+
function finalizeParams(callee: util.AnyFunc, params: $ParseParams | undefined): $ParseParams {
11+
return { callee: params?.callee ?? callee, Err: params?.Err };
12+
}
713

814
/////////// METHODS ///////////
915
export type $Parse = <T extends schemas.$ZodType>(
1016
schema: T,
1117
value: unknown,
1218
_ctx?: schemas.ParseContext<errors.$ZodIssue>,
13-
_params?: { callee?: util.AnyFunc; Err?: $ZodErrorClass }
19+
_params?: $ParseParams
1420
) => core.output<T>;
1521

16-
export const _parse: (_Err: $ZodErrorClass) => $Parse = (_Err) => (schema, value, _ctx, _params) => {
17-
const ctx: schemas.ParseContextInternal = _ctx ? { ..._ctx, async: false } : { async: false };
18-
const result = schema._zod.run({ value, issues: [] }, ctx);
19-
if (result instanceof Promise) {
20-
throw new core.$ZodAsyncError();
21-
}
22-
if (result.issues.length) {
23-
const e = new (_params?.Err ?? _Err)(result.issues.map((iss) => util.finalizeIssue(iss, ctx, core.config())));
24-
util.captureStackTrace(e, _params?.callee);
25-
throw e;
26-
}
27-
return result.value as core.output<typeof schema>;
22+
export const _parse: (_Err: $ZodErrorClass) => $Parse = (_Err) => {
23+
const fn: $Parse = (schema, value, _ctx, _params) => {
24+
const ctx: schemas.ParseContextInternal = _ctx ? { ..._ctx, async: false } : { async: false };
25+
const result = schema._zod.run({ value, issues: [] }, ctx);
26+
if (result instanceof Promise) {
27+
throw new core.$ZodAsyncError();
28+
}
29+
if (result.issues.length) {
30+
const e = new (_params?.Err ?? _Err)(result.issues.map((iss) => util.finalizeIssue(iss, ctx, core.config())));
31+
util.captureStackTrace(e, _params?.callee ?? fn);
32+
throw e;
33+
}
34+
return result.value as core.output<typeof schema>;
35+
};
36+
return fn;
2837
};
2938

3039
export const parse: $Parse = /* @__PURE__*/ _parse(errors.$ZodRealError);
@@ -33,19 +42,22 @@ export type $ParseAsync = <T extends schemas.$ZodType>(
3342
schema: T,
3443
value: unknown,
3544
_ctx?: schemas.ParseContext<errors.$ZodIssue>,
36-
_params?: { callee?: util.AnyFunc; Err?: $ZodErrorClass }
45+
_params?: $ParseParams
3746
) => Promise<core.output<T>>;
3847

39-
export const _parseAsync: (_Err: $ZodErrorClass) => $ParseAsync = (_Err) => async (schema, value, _ctx, params) => {
40-
const ctx: schemas.ParseContextInternal = _ctx ? { ..._ctx, async: true } : { async: true };
41-
let result = schema._zod.run({ value, issues: [] }, ctx);
42-
if (result instanceof Promise) result = await result;
43-
if (result.issues.length) {
44-
const e = new (params?.Err ?? _Err)(result.issues.map((iss) => util.finalizeIssue(iss, ctx, core.config())));
45-
util.captureStackTrace(e, params?.callee);
46-
throw e;
47-
}
48-
return result.value as core.output<typeof schema>;
48+
export const _parseAsync: (_Err: $ZodErrorClass) => $ParseAsync = (_Err) => {
49+
const fn: $ParseAsync = async (schema, value, _ctx, params) => {
50+
const ctx: schemas.ParseContextInternal = _ctx ? { ..._ctx, async: true } : { async: true };
51+
let result = schema._zod.run({ value, issues: [] }, ctx);
52+
if (result instanceof Promise) result = await result;
53+
if (result.issues.length) {
54+
const e = new (params?.Err ?? _Err)(result.issues.map((iss) => util.finalizeIssue(iss, ctx, core.config())));
55+
util.captureStackTrace(e, params?.callee ?? fn);
56+
throw e;
57+
}
58+
return result.value as core.output<typeof schema>;
59+
};
60+
return fn;
4961
};
5062

5163
export const parseAsync: $ParseAsync = /* @__PURE__*/ _parseAsync(errors.$ZodRealError);
@@ -97,49 +109,69 @@ export const safeParseAsync: $SafeParseAsync = /* @__PURE__*/ _safeParseAsync(er
97109
export type $Encode = <T extends schemas.$ZodType>(
98110
schema: T,
99111
value: core.output<T>,
100-
_ctx?: schemas.ParseContext<errors.$ZodIssue>
112+
_ctx?: schemas.ParseContext<errors.$ZodIssue>,
113+
_params?: $ParseParams
101114
) => core.input<T>;
102115

103-
export const _encode: (_Err: $ZodErrorClass) => $Encode = (_Err) => (schema, value, _ctx) => {
104-
const ctx = _ctx ? { ..._ctx, direction: "backward" as const } : { direction: "backward" as const };
105-
return _parse(_Err)(schema, value, ctx as any) as any;
116+
export const _encode: (_Err: $ZodErrorClass) => $Encode = (_Err) => {
117+
const parse = _parse(_Err);
118+
const fn: $Encode = (schema, value, _ctx, _params) => {
119+
const ctx = _ctx ? { ..._ctx, direction: "backward" as const } : { direction: "backward" as const };
120+
return parse(schema, value, ctx as any, finalizeParams(fn, _params)) as any;
121+
};
122+
return fn;
106123
};
107124

108125
export const encode: $Encode = /* @__PURE__*/ _encode(errors.$ZodRealError);
109126

110127
export type $Decode = <T extends schemas.$ZodType>(
111128
schema: T,
112129
value: core.input<T>,
113-
_ctx?: schemas.ParseContext<errors.$ZodIssue>
130+
_ctx?: schemas.ParseContext<errors.$ZodIssue>,
131+
_params?: $ParseParams
114132
) => core.output<T>;
115133

116-
export const _decode: (_Err: $ZodErrorClass) => $Decode = (_Err) => (schema, value, _ctx) => {
117-
return _parse(_Err)(schema, value, _ctx);
134+
export const _decode: (_Err: $ZodErrorClass) => $Decode = (_Err) => {
135+
const parse = _parse(_Err);
136+
const fn: $Decode = (schema, value, _ctx, _params) => {
137+
return parse(schema, value, _ctx, finalizeParams(fn, _params));
138+
};
139+
return fn;
118140
};
119141

120142
export const decode: $Decode = /* @__PURE__*/ _decode(errors.$ZodRealError);
121143

122144
export type $EncodeAsync = <T extends schemas.$ZodType>(
123145
schema: T,
124146
value: core.output<T>,
125-
_ctx?: schemas.ParseContext<errors.$ZodIssue>
147+
_ctx?: schemas.ParseContext<errors.$ZodIssue>,
148+
_params?: $ParseParams
126149
) => Promise<core.input<T>>;
127150

128-
export const _encodeAsync: (_Err: $ZodErrorClass) => $EncodeAsync = (_Err) => async (schema, value, _ctx) => {
129-
const ctx = _ctx ? { ..._ctx, direction: "backward" as const } : { direction: "backward" as const };
130-
return _parseAsync(_Err)(schema, value, ctx as any) as any;
151+
export const _encodeAsync: (_Err: $ZodErrorClass) => $EncodeAsync = (_Err) => {
152+
const parseAsync = _parseAsync(_Err);
153+
const fn: $EncodeAsync = async (schema, value, _ctx, _params) => {
154+
const ctx = _ctx ? { ..._ctx, direction: "backward" as const } : { direction: "backward" as const };
155+
return (await parseAsync(schema, value, ctx as any, finalizeParams(fn, _params))) as any;
156+
};
157+
return fn;
131158
};
132159

133160
export const encodeAsync: $EncodeAsync = /* @__PURE__*/ _encodeAsync(errors.$ZodRealError);
134161

135162
export type $DecodeAsync = <T extends schemas.$ZodType>(
136163
schema: T,
137164
value: core.input<T>,
138-
_ctx?: schemas.ParseContext<errors.$ZodIssue>
165+
_ctx?: schemas.ParseContext<errors.$ZodIssue>,
166+
_params?: $ParseParams
139167
) => Promise<core.output<T>>;
140168

141-
export const _decodeAsync: (_Err: $ZodErrorClass) => $DecodeAsync = (_Err) => async (schema, value, _ctx) => {
142-
return _parseAsync(_Err)(schema, value, _ctx);
169+
export const _decodeAsync: (_Err: $ZodErrorClass) => $DecodeAsync = (_Err) => {
170+
const parseAsync = _parseAsync(_Err);
171+
const fn: $DecodeAsync = async (schema, value, _ctx, _params) => {
172+
return await parseAsync(schema, value, _ctx, finalizeParams(fn, _params));
173+
};
174+
return fn;
143175
};
144176

145177
export const decodeAsync: $DecodeAsync = /* @__PURE__*/ _decodeAsync(errors.$ZodRealError);

0 commit comments

Comments
 (0)