Skip to content

Commit 0647a92

Browse files
committed
feat(v4): add parseMaybeAsync / safeParseMaybeAsync (#5379)
Sync-when-possible parsing: returns a value synchronously when the schema is sync, a Promise only when an async refinement/transform is hit. Implementation: - Run sync first (ctx.async = false) so ZodObject's JIT fastpass engages on sync-only schemas; fall back to async on $ZodAsyncError. - Cache async-observed schemas in a WeakSet so subsequent calls skip the sync attempt and avoid the throw cost on async-capable schemas. - ~standard.validate routed through safeParseMaybeAsync (replaces the previous safeParse + try/catch + safeParseAsync fallback). Caveat — side-effect doubling on the async fallback path: sync work running before the first async step executes once during the sync attempt and again during the async run. Documented in JSDoc; users should prefer parseAsync for non-idempotent sync transforms. Bench (packages/bench/object-maybe-async.ts): - flat sync object: parity with safeParse. - nested + discriminated sync: parity with safeParse (fastpass engages end-to-end). - mostly-sync + 1 async refine: ~1.4x of safeParseAsync after the first call (down from ~14x without the WeakSet cache).
1 parent ee7376a commit 0647a92

9 files changed

Lines changed: 327 additions & 6 deletions

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import * as z from "../zod/src/v4/index.js";
2+
import { makeData } from "./benchUtil.js";
3+
import { metabench } from "./metabench.js";
4+
5+
// 1. small flat object — purely sync, exercises ZodObject fastpass directly.
6+
{
7+
const schema = z.object({
8+
string: z.string(),
9+
boolean: z.boolean(),
10+
number: z.number(),
11+
});
12+
const DATA = makeData(1000, () => ({
13+
number: Math.random(),
14+
string: `${Math.random()}`,
15+
boolean: Math.random() > 0.5,
16+
}));
17+
const bench = metabench("flat sync 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+
await bench.run();
26+
}
27+
28+
// 2. nested + discriminated union — sync but non-trivial; proves the
29+
// fastpass engages end-to-end down the schema tree.
30+
{
31+
const inner = z.object({ tag: z.string(), score: z.number() });
32+
const schema = z.object({
33+
id: z.string(),
34+
nested: z.object({ a: z.string(), b: z.number(), c: z.boolean() }),
35+
items: z.array(inner),
36+
kind: z.discriminatedUnion("type", [
37+
z.object({ type: z.literal("a"), x: z.string() }),
38+
z.object({ type: z.literal("b"), y: z.number() }),
39+
]),
40+
});
41+
const DATA = makeData(500, () => ({
42+
id: `${Math.random()}`,
43+
nested: { a: "x", b: 1, c: true },
44+
items: [
45+
{ tag: "p", score: 1 },
46+
{ tag: "q", score: 2 },
47+
],
48+
kind: Math.random() > 0.5 ? { type: "a", x: "ok" } : { type: "b", y: 1 },
49+
}));
50+
const bench = metabench("nested + discriminated sync — safeParse vs safeParseMaybeAsync", {
51+
safeParse() {
52+
for (const _ of DATA) schema.safeParse(_);
53+
},
54+
safeParseMaybeAsync() {
55+
for (const _ of DATA) schema.safeParseMaybeAsync(_);
56+
},
57+
});
58+
await bench.run();
59+
}
60+
61+
// 3. mostly-sync schema with one deep async refine — quantifies the
62+
// double-walk cost on the async fallback path vs safeParseAsync.
63+
{
64+
const schema = z.object({
65+
id: z.string(),
66+
nested: z.object({ a: z.string(), b: z.number() }),
67+
tail: z.string().refine(async (s) => s.length >= 0),
68+
});
69+
const DATA = makeData(200, () => ({
70+
id: `${Math.random()}`,
71+
nested: { a: "x", b: 1 },
72+
tail: "y",
73+
}));
74+
const bench = metabench("mostly-sync + 1 async refine — safeParseAsync vs safeParseMaybeAsync", {
75+
async safeParseAsync() {
76+
for (const _ of DATA) await schema.safeParseAsync(_);
77+
},
78+
async safeParseMaybeAsync() {
79+
for (const _ of DATA) await schema.safeParseMaybeAsync(_);
80+
},
81+
});
82+
await bench.run();
83+
}

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ export const parseAsync: <T extends core.$ZodType>(
1919
_params?: { callee?: core.util.AnyFunc; Err?: core.$ZodErrorClass }
2020
) => Promise<core.output<T>> = /* @__PURE__ */ core._parseAsync(ZodRealError);
2121

22+
export const parseMaybeAsync: <T extends core.$ZodType>(
23+
schema: T,
24+
value: unknown,
25+
_ctx?: core.ParseContext<core.$ZodIssue>,
26+
_params?: { callee?: core.util.AnyFunc; Err?: core.$ZodErrorClass }
27+
) => core.util.MaybeAsync<core.output<T>> = /* @__PURE__ */ core._parseMaybeAsync(ZodRealError);
28+
2229
export const safeParse: <T extends core.$ZodType>(
2330
schema: T,
2431
value: unknown,
@@ -32,6 +39,14 @@ export const safeParseAsync: <T extends core.$ZodType>(
3239
_ctx?: core.ParseContext<core.$ZodIssue>
3340
) => Promise<ZodSafeParseResult<core.output<T>>> = /* @__PURE__ */ core._safeParseAsync(ZodRealError) as any;
3441

42+
export const safeParseMaybeAsync: <T extends core.$ZodType>(
43+
schema: T,
44+
value: unknown,
45+
_ctx?: core.ParseContext<core.$ZodIssue>
46+
) => core.util.MaybeAsync<ZodSafeParseResult<core.output<T>>> = /* @__PURE__ */ core._safeParseMaybeAsync(
47+
ZodRealError
48+
) as any;
49+
3550
// Codec functions
3651
export const encode: <T extends core.$ZodType>(
3752
schema: T,

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,13 @@ export interface ZodType<
115115
parse(data: unknown, params?: core.ParseContext<core.$ZodIssue>): core.output<this>;
116116
safeParse(data: unknown, params?: core.ParseContext<core.$ZodIssue>): parse.ZodSafeParseResult<core.output<this>>;
117117
parseAsync(data: unknown, params?: core.ParseContext<core.$ZodIssue>): Promise<core.output<this>>;
118+
/** Sync when possible; Promise on async refinement. Sync transforms run twice if the schema then hits an async step — use `parseAsync` if non-idempotent. */
119+
parseMaybeAsync(data: unknown, params?: core.ParseContext<core.$ZodIssue>): core.util.MaybeAsync<core.output<this>>;
120+
/** Safe variant of `parseMaybeAsync`. Same side-effect caveat. */
121+
safeParseMaybeAsync(
122+
data: unknown,
123+
params?: core.ParseContext<core.$ZodIssue>
124+
): core.util.MaybeAsync<parse.ZodSafeParseResult<core.output<this>>>;
118125
safeParseAsync(
119126
data: unknown,
120127
params?: core.ParseContext<core.$ZodIssue>
@@ -235,6 +242,8 @@ export const ZodType: core.$constructor<ZodType> = /*@__PURE__*/ core.$construct
235242
inst.safeParse = (data, params) => parse.safeParse(inst, data, params);
236243
inst.parseAsync = async (data, params) => parse.parseAsync(inst, data, params, { callee: inst.parseAsync });
237244
inst.safeParseAsync = async (data, params) => parse.safeParseAsync(inst, data, params);
245+
inst.parseMaybeAsync = (data, params) => parse.parseMaybeAsync(inst, data, params, { callee: inst.parseMaybeAsync });
246+
inst.safeParseMaybeAsync = (data, params) => parse.safeParseMaybeAsync(inst, data, params);
238247
inst.spa = inst.safeParseAsync;
239248
inst.encode = (data, params) => parse.encode(inst, data, params);
240249
inst.decode = (data, params) => parse.decode(inst, data, params);
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { expect, test } from "vitest";
2+
import * as z from "zod/v4";
3+
4+
// Tests for #5379.
5+
6+
test("parseMaybeAsync returns the value sync when no async work is involved", () => {
7+
const schema = z.string();
8+
const result = schema.parseMaybeAsync("hello");
9+
expect(result instanceof Promise).toBe(false);
10+
expect(result).toBe("hello");
11+
});
12+
13+
test("parseMaybeAsync returns a Promise when an async refinement is encountered", async () => {
14+
const schema = z.string().refine(async (v) => v.length > 0);
15+
const result = schema.parseMaybeAsync("hello");
16+
expect(result instanceof Promise).toBe(true);
17+
await expect(result).resolves.toBe("hello");
18+
});
19+
20+
test("parseMaybeAsync throws sync on validation failure with no async work", () => {
21+
const schema = z.string();
22+
expect(() => schema.parseMaybeAsync(42)).toThrow(z.ZodError);
23+
});
24+
25+
test("parseMaybeAsync rejects with ZodError on async validation failure", async () => {
26+
const schema = z.string().refine(async (v) => v.length > 5, { message: "too short" });
27+
const result = schema.parseMaybeAsync("hi");
28+
expect(result instanceof Promise).toBe(true);
29+
await expect(result).rejects.toThrow(z.ZodError);
30+
});
31+
32+
test("safeParseMaybeAsync mirrors parseMaybeAsync but wraps the result", () => {
33+
const sync = z.string().safeParseMaybeAsync("hi");
34+
expect(sync instanceof Promise).toBe(false);
35+
expect(sync).toEqual({ success: true, data: "hi" });
36+
37+
const failure = z.string().safeParseMaybeAsync(42);
38+
expect(failure instanceof Promise).toBe(false);
39+
if (!(failure instanceof Promise)) expect(failure.success).toBe(false);
40+
});
41+
42+
test("safeParseMaybeAsync awaits async refinements (success)", async () => {
43+
const schema = z.string().refine(async (v) => v.length > 0);
44+
const result = schema.safeParseMaybeAsync("hi");
45+
expect(result instanceof Promise).toBe(true);
46+
const settled = await result;
47+
expect(settled).toEqual({ success: true, data: "hi" });
48+
});
49+
50+
test("safeParseMaybeAsync awaits async refinements (failure)", async () => {
51+
const schema = z.string().refine(async (v) => v.length > 5, { message: "too short" });
52+
const result = schema.safeParseMaybeAsync("hi");
53+
expect(result instanceof Promise).toBe(true);
54+
const settled = await result;
55+
expect(settled.success).toBe(false);
56+
if (!settled.success) expect(settled.error.issues[0].message).toBe("too short");
57+
});
58+
59+
// Standard Schema integration: validate() now uses safeParseMaybeAsync
60+
// internally, so a fully-sync schema must NOT return a Promise.
61+
test("StandardSchema validate is sync when schema has no async work", () => {
62+
const schema = z.string();
63+
const validated = schema["~standard"].validate("hi");
64+
expect(validated instanceof Promise).toBe(false);
65+
});
66+
67+
test("StandardSchema validate is async when schema has async refinements", async () => {
68+
const schema = z.string().refine(async (v) => v.length > 0);
69+
const validated = schema["~standard"].validate("hi");
70+
expect(validated instanceof Promise).toBe(true);
71+
await expect(validated).resolves.toEqual({ value: "hi" });
72+
});

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

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,64 @@ export const _parseAsync: (_Err: $ZodErrorClass) => $ParseAsync = (_Err) => asyn
5050

5151
export const parseAsync: $ParseAsync = /* @__PURE__*/ _parseAsync(errors.$ZodRealError);
5252

53+
export type $ParseMaybeAsync = <T extends schemas.$ZodType>(
54+
schema: T,
55+
value: unknown,
56+
_ctx?: schemas.ParseContext<errors.$ZodIssue>,
57+
_params?: { callee?: util.AnyFunc; Err?: $ZodErrorClass }
58+
) => util.MaybeAsync<core.output<T>>;
59+
60+
// Schemas observed to be async are skipped past the sync attempt on subsequent calls.
61+
const _asyncObserved = new WeakSet<schemas.$ZodType>();
62+
63+
export const _parseMaybeAsync: (_Err: $ZodErrorClass) => $ParseMaybeAsync =
64+
(_Err) => (schema, value, _ctx, _params) => {
65+
const ErrCls = _params?.Err ?? _Err;
66+
if (_asyncObserved.has(schema)) {
67+
const ctx: schemas.ParseContextInternal = _ctx ? { ..._ctx, async: true } : { async: true };
68+
const ar = schema._zod.run({ value, issues: [] }, ctx);
69+
const finalize = (rr: schemas.ParsePayload) => {
70+
if (rr.issues.length) {
71+
const e = new ErrCls(rr.issues.map((iss) => util.finalizeIssue(iss, ctx, core.config())));
72+
util.captureStackTrace(e, _params?.callee);
73+
throw e;
74+
}
75+
return rr.value as core.output<typeof schema>;
76+
};
77+
return ar instanceof Promise ? ar.then(finalize) : finalize(ar);
78+
}
79+
// Sync first: ZodObject's JIT fastpass requires ctx.async === false.
80+
const syncCtx: schemas.ParseContextInternal = _ctx ? { ..._ctx, async: false } : { async: false };
81+
let syncResult: schemas.ParsePayload | Promise<schemas.ParsePayload> | undefined;
82+
try {
83+
syncResult = schema._zod.run({ value, issues: [] }, syncCtx);
84+
} catch (e) {
85+
if (!(e instanceof core.$ZodAsyncError)) throw e;
86+
_asyncObserved.add(schema);
87+
}
88+
if (syncResult !== undefined && !(syncResult instanceof Promise)) {
89+
if (syncResult.issues.length) {
90+
const e = new ErrCls(syncResult.issues.map((iss) => util.finalizeIssue(iss, syncCtx, core.config())));
91+
util.captureStackTrace(e, _params?.callee);
92+
throw e;
93+
}
94+
return syncResult.value as core.output<typeof schema>;
95+
}
96+
const ctx: schemas.ParseContextInternal = _ctx ? { ..._ctx, async: true } : { async: true };
97+
const ar = schema._zod.run({ value, issues: [] }, ctx);
98+
const finalize = (rr: schemas.ParsePayload) => {
99+
if (rr.issues.length) {
100+
const e = new ErrCls(rr.issues.map((iss) => util.finalizeIssue(iss, ctx, core.config())));
101+
util.captureStackTrace(e, _params?.callee);
102+
throw e;
103+
}
104+
return rr.value as core.output<typeof schema>;
105+
};
106+
return ar instanceof Promise ? ar.then(finalize) : finalize(ar);
107+
};
108+
109+
export const parseMaybeAsync: $ParseMaybeAsync = /* @__PURE__*/ _parseMaybeAsync(errors.$ZodRealError);
110+
53111
export type $SafeParse = <T extends schemas.$ZodType>(
54112
schema: T,
55113
value: unknown,
@@ -93,6 +151,52 @@ export const _safeParseAsync: (_Err: $ZodErrorClass) => $SafeParseAsync = (_Err)
93151

94152
export const safeParseAsync: $SafeParseAsync = /* @__PURE__*/ _safeParseAsync(errors.$ZodRealError);
95153

154+
export type $SafeParseMaybeAsync = <T extends schemas.$ZodType>(
155+
schema: T,
156+
value: unknown,
157+
_ctx?: schemas.ParseContext<errors.$ZodIssue>
158+
) => util.MaybeAsync<util.SafeParseResult<core.output<T>>>;
159+
160+
export const _safeParseMaybeAsync: (_Err: $ZodErrorClass) => $SafeParseMaybeAsync = (_Err) => (schema, value, _ctx) => {
161+
if (_asyncObserved.has(schema)) {
162+
const ctx: schemas.ParseContextInternal = _ctx ? { ..._ctx, async: true } : { async: true };
163+
const ar = schema._zod.run({ value, issues: [] }, ctx);
164+
const finalize = (rr: schemas.ParsePayload) =>
165+
rr.issues.length
166+
? { success: false, error: new _Err(rr.issues.map((iss) => util.finalizeIssue(iss, ctx, core.config()))) }
167+
: { success: true, data: rr.value };
168+
return (ar instanceof Promise ? ar.then(finalize) : finalize(ar)) as any;
169+
}
170+
// Sync first: ZodObject's JIT fastpass requires ctx.async === false.
171+
const syncCtx: schemas.ParseContextInternal = _ctx ? { ..._ctx, async: false } : { async: false };
172+
let syncResult: schemas.ParsePayload | Promise<schemas.ParsePayload> | undefined;
173+
try {
174+
syncResult = schema._zod.run({ value, issues: [] }, syncCtx);
175+
} catch (e) {
176+
if (!(e instanceof core.$ZodAsyncError)) throw e;
177+
_asyncObserved.add(schema);
178+
}
179+
if (syncResult !== undefined && !(syncResult instanceof Promise)) {
180+
return (
181+
syncResult.issues.length
182+
? {
183+
success: false,
184+
error: new _Err(syncResult.issues.map((iss) => util.finalizeIssue(iss, syncCtx, core.config()))),
185+
}
186+
: { success: true, data: syncResult.value }
187+
) as any;
188+
}
189+
const ctx: schemas.ParseContextInternal = _ctx ? { ..._ctx, async: true } : { async: true };
190+
const ar = schema._zod.run({ value, issues: [] }, ctx);
191+
const finalize = (rr: schemas.ParsePayload) =>
192+
rr.issues.length
193+
? { success: false, error: new _Err(rr.issues.map((iss) => util.finalizeIssue(iss, ctx, core.config()))) }
194+
: { success: true, data: rr.value };
195+
return (ar instanceof Promise ? ar.then(finalize) : finalize(ar)) as any;
196+
};
197+
198+
export const safeParseMaybeAsync: $SafeParseMaybeAsync = /* @__PURE__*/ _safeParseMaybeAsync(errors.$ZodRealError);
199+
96200
// Codec functions
97201
export type $Encode = <T extends schemas.$ZodType>(
98202
schema: T,

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

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import * as core from "./core.js";
44
import { Doc } from "./doc.js";
55
import type * as errors from "./errors.js";
66
import type * as JSONSchema from "./json-schema.js";
7-
import { parse, parseAsync, safeParse, safeParseAsync } from "./parse.js";
7+
import { parse, parseAsync, safeParseMaybeAsync } from "./parse.js";
88
import * as regexes from "./regexes.js";
99
import type { StandardSchemaV1 } from "./standard-schema.js";
1010
import type { ProcessParams, ToJSONSchemaContext } from "./to-json-schema.js";
@@ -307,12 +307,11 @@ export const $ZodType: core.$constructor<$ZodType> = /*@__PURE__*/ core.$constru
307307
// Lazy initialize ~standard to avoid creating objects for every schema
308308
util.defineLazy(inst, "~standard", () => ({
309309
validate: (value: unknown) => {
310-
try {
311-
const r = safeParse(inst, value);
312-
return r.success ? { value: r.data } : { issues: r.error?.issues };
313-
} catch (_) {
314-
return safeParseAsync(inst, value).then((r) => (r.success ? { value: r.data } : { issues: r.error?.issues }));
310+
const r = safeParseMaybeAsync(inst, value);
311+
if (r instanceof Promise) {
312+
return r.then((rr) => (rr.success ? { value: rr.data } : { issues: rr.error?.issues }));
315313
}
314+
return r.success ? { value: r.data } : { issues: r.error?.issues };
316315
},
317316
vendor: "zod",
318317
version: 1 as const,

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ export {
33
safeParse,
44
parseAsync,
55
safeParseAsync,
6+
parseMaybeAsync,
7+
safeParseMaybeAsync,
68
encode,
79
decode,
810
encodeAsync,

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ export interface ZodMiniType<
3434
data: unknown,
3535
params?: core.ParseContext<core.$ZodIssue>
3636
): Promise<util.SafeParseResult<core.output<this>>>;
37+
/** Sync when possible; Promise on async refinement. Sync transforms run twice if the schema then hits an async step — use `parseAsync` if non-idempotent. */
38+
parseMaybeAsync(data: unknown, params?: core.ParseContext<core.$ZodIssue>): util.MaybeAsync<core.output<this>>;
39+
/** Safe variant of `parseMaybeAsync`. Same side-effect caveat. */
40+
safeParseMaybeAsync(
41+
data: unknown,
42+
params?: core.ParseContext<core.$ZodIssue>
43+
): util.MaybeAsync<util.SafeParseResult<core.output<this>>>;
3744
apply<T>(fn: (schema: this) => T): T;
3845
}
3946

@@ -53,6 +60,9 @@ export const ZodMiniType: core.$constructor<ZodMiniType> = /*@__PURE__*/ core.$c
5360
inst.safeParse = (data, params) => parse.safeParse(inst, data, params);
5461
inst.parseAsync = async (data, params) => parse.parseAsync(inst, data, params, { callee: inst.parseAsync });
5562
inst.safeParseAsync = async (data, params) => parse.safeParseAsync(inst, data, params);
63+
inst.parseMaybeAsync = (data, params) =>
64+
parse.parseMaybeAsync(inst, data, params, { callee: inst.parseMaybeAsync });
65+
inst.safeParseMaybeAsync = (data, params) => parse.safeParseMaybeAsync(inst, data, params);
5666
inst.check = (...checks) => {
5767
return inst.clone(
5868
{

0 commit comments

Comments
 (0)