Skip to content

Commit b0f94c8

Browse files
committed
perf(v4): preserve JIT fastpass in parseMaybeAsync / safeParseMaybeAsync
The previous implementation forced ctx.async = true upfront, which bypassed ZodObject's JIT fastpass (gated on ctx?.async === false strict) and caused a ~5x slowdown on the sync hot path that 99% of consumers take. This was the regression that closed #5924. Run sync first (async: false) so the fastpass engages; only fall back to the async path on $ZodAsyncError or a Promise return. Closures in the hot path are flattened (the previous higher-order finalize factory allocated per call). Sync object schemas now match safeParse within benchmark noise; async schemas pay one extra short-circuited sync pass that bails on the first async check, which is the right tradeoff given async is the minority case. Refs #5379, #5924.
1 parent 4b2e992 commit b0f94c8

2 files changed

Lines changed: 74 additions & 12 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import * as z from "../zod/src/v4/index.js";
2+
import { makeData } from "./benchUtil.js";
3+
import { metabench } from "./metabench.js";
4+
5+
const schema = z.object({
6+
string: z.string(),
7+
boolean: z.boolean(),
8+
number: z.number(),
9+
});
10+
11+
const DATA = makeData(1000, () => ({
12+
number: Math.random(),
13+
string: `${Math.random()}`,
14+
boolean: Math.random() > 0.5,
15+
}));
16+
17+
const bench = metabench("small: z.object().safeParse vs safeParseMaybeAsync", {
18+
safeParse() {
19+
for (const _ of DATA) schema.safeParse(_);
20+
},
21+
safeParseMaybeAsync() {
22+
for (const _ of DATA) schema.safeParseMaybeAsync(_);
23+
},
24+
});
25+
26+
await bench.run();

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

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -59,17 +59,36 @@ export type $ParseMaybeAsync = <T extends schemas.$ZodType>(
5959

6060
export const _parseMaybeAsync: (_Err: $ZodErrorClass) => $ParseMaybeAsync =
6161
(_Err) => (schema, value, _ctx, _params) => {
62+
// Sync first: ZodObject's JIT fastpass requires ctx.async === false.
63+
const syncCtx: schemas.ParseContextInternal = _ctx ? { ..._ctx, async: false } : { async: false };
64+
const ErrCls = _params?.Err ?? _Err;
65+
let r: schemas.ParsePayload | Promise<schemas.ParsePayload> | undefined;
66+
let asyncFallback = false;
67+
try {
68+
r = schema._zod.run({ value, issues: [] }, syncCtx);
69+
} catch (e) {
70+
if (!(e instanceof core.$ZodAsyncError)) throw e;
71+
asyncFallback = true;
72+
}
73+
if (!asyncFallback && !(r instanceof Promise)) {
74+
if (r!.issues.length) {
75+
const e = new ErrCls(r!.issues.map((iss) => util.finalizeIssue(iss, syncCtx, core.config())));
76+
util.captureStackTrace(e, _params?.callee);
77+
throw e;
78+
}
79+
return r!.value as core.output<typeof schema>;
80+
}
6281
const ctx: schemas.ParseContextInternal = _ctx ? { ..._ctx, async: true } : { async: true };
63-
const finalize = (r: schemas.ParsePayload) => {
64-
if (r.issues.length) {
65-
const e = new (_params?.Err ?? _Err)(r.issues.map((iss) => util.finalizeIssue(iss, ctx, core.config())));
82+
const ar = schema._zod.run({ value, issues: [] }, ctx);
83+
const finalize = (rr: schemas.ParsePayload) => {
84+
if (rr.issues.length) {
85+
const e = new ErrCls(rr.issues.map((iss) => util.finalizeIssue(iss, ctx, core.config())));
6686
util.captureStackTrace(e, _params?.callee);
6787
throw e;
6888
}
69-
return r.value as core.output<typeof schema>;
89+
return rr.value as core.output<typeof schema>;
7090
};
71-
const result = schema._zod.run({ value, issues: [] }, ctx);
72-
return result instanceof Promise ? result.then(finalize) : finalize(result);
91+
return ar instanceof Promise ? ar.then(finalize) : finalize(ar);
7392
};
7493

7594
export const parseMaybeAsync: $ParseMaybeAsync = /* @__PURE__*/ _parseMaybeAsync(errors.$ZodRealError);
@@ -124,13 +143,30 @@ export type $SafeParseMaybeAsync = <T extends schemas.$ZodType>(
124143
) => util.MaybeAsync<util.SafeParseResult<core.output<T>>>;
125144

126145
export const _safeParseMaybeAsync: (_Err: $ZodErrorClass) => $SafeParseMaybeAsync = (_Err) => (schema, value, _ctx) => {
146+
// Sync first: ZodObject's JIT fastpass requires ctx.async === false.
147+
const syncCtx: schemas.ParseContextInternal = _ctx ? { ..._ctx, async: false } : { async: false };
148+
let r: schemas.ParsePayload | Promise<schemas.ParsePayload> | undefined;
149+
let asyncFallback = false;
150+
try {
151+
r = schema._zod.run({ value, issues: [] }, syncCtx);
152+
} catch (e) {
153+
if (!(e instanceof core.$ZodAsyncError)) throw e;
154+
asyncFallback = true;
155+
}
156+
if (!asyncFallback && !(r instanceof Promise)) {
157+
return (
158+
r!.issues.length
159+
? { success: false, error: new _Err(r!.issues.map((iss) => util.finalizeIssue(iss, syncCtx, core.config()))) }
160+
: { success: true, data: r!.value }
161+
) as any;
162+
}
127163
const ctx: schemas.ParseContextInternal = _ctx ? { ..._ctx, async: true } : { async: true };
128-
const finalize = (r: schemas.ParsePayload) =>
129-
r.issues.length
130-
? { success: false, error: new _Err(r.issues.map((iss) => util.finalizeIssue(iss, ctx, core.config()))) }
131-
: { success: true, data: r.value };
132-
const result = schema._zod.run({ value, issues: [] }, ctx);
133-
return (result instanceof Promise ? result.then(finalize) : finalize(result)) as any;
164+
const ar = schema._zod.run({ value, issues: [] }, ctx);
165+
const finalize = (rr: schemas.ParsePayload) =>
166+
rr.issues.length
167+
? { success: false, error: new _Err(rr.issues.map((iss) => util.finalizeIssue(iss, ctx, core.config()))) }
168+
: { success: true, data: rr.value };
169+
return (ar instanceof Promise ? ar.then(finalize) : finalize(ar)) as any;
134170
};
135171

136172
export const safeParseMaybeAsync: $SafeParseMaybeAsync = /* @__PURE__*/ _safeParseMaybeAsync(errors.$ZodRealError);

0 commit comments

Comments
 (0)