Skip to content

Commit 18685c6

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 18685c6

8 files changed

Lines changed: 307 additions & 0 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 `transform` / `refine` / checks before the first async step run twice on the async fallback — prefer `parseAsync` for non-idempotent steps. */
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: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,56 @@ 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+
// Async-observed schemas skip the sync attempt. Inlined in both factories: a shared
61+
// `{ ctx, result }` helper regresses the sync path ~12x (per-call alloc + lost inlining).
62+
const _asyncObserved = new WeakSet<schemas.$ZodType>();
63+
64+
export const _parseMaybeAsync: (_Err: $ZodErrorClass) => $ParseMaybeAsync =
65+
(_Err) => (schema, value, _ctx, _params) => {
66+
const ErrCls = _params?.Err ?? _Err;
67+
if (!_asyncObserved.has(schema)) {
68+
// Sync first: ZodObject's JIT fastpass requires ctx.async === false.
69+
const syncCtx: schemas.ParseContextInternal = _ctx ? { ..._ctx, async: false } : { async: false };
70+
let syncResult: schemas.ParsePayload | Promise<schemas.ParsePayload> | undefined;
71+
try {
72+
syncResult = schema._zod.run({ value, issues: [] }, syncCtx);
73+
} catch (e) {
74+
if (!(e instanceof core.$ZodAsyncError)) throw e;
75+
_asyncObserved.add(schema);
76+
}
77+
if (syncResult !== undefined && !(syncResult instanceof Promise)) {
78+
if (syncResult.issues.length) {
79+
const e = new ErrCls(syncResult.issues.map((iss) => util.finalizeIssue(iss, syncCtx, core.config())));
80+
util.captureStackTrace(e, _params?.callee);
81+
throw e;
82+
}
83+
return syncResult.value as core.output<typeof schema>;
84+
}
85+
// Defensive: schema returned a Promise without throwing — treat as async-capable.
86+
if (syncResult instanceof Promise) _asyncObserved.add(schema);
87+
}
88+
const ctx: schemas.ParseContextInternal = _ctx ? { ..._ctx, async: true } : { async: true };
89+
const ar = schema._zod.run({ value, issues: [] }, ctx);
90+
const finalize = (rr: schemas.ParsePayload) => {
91+
if (rr.issues.length) {
92+
const e = new ErrCls(rr.issues.map((iss) => util.finalizeIssue(iss, ctx, core.config())));
93+
util.captureStackTrace(e, _params?.callee);
94+
throw e;
95+
}
96+
return rr.value as core.output<typeof schema>;
97+
};
98+
return ar instanceof Promise ? ar.then(finalize) : finalize(ar);
99+
};
100+
101+
export const parseMaybeAsync: $ParseMaybeAsync = /* @__PURE__*/ _parseMaybeAsync(errors.$ZodRealError);
102+
53103
export type $SafeParse = <T extends schemas.$ZodType>(
54104
schema: T,
55105
value: unknown,
@@ -93,6 +143,45 @@ export const _safeParseAsync: (_Err: $ZodErrorClass) => $SafeParseAsync = (_Err)
93143

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

146+
export type $SafeParseMaybeAsync = <T extends schemas.$ZodType>(
147+
schema: T,
148+
value: unknown,
149+
_ctx?: schemas.ParseContext<errors.$ZodIssue>
150+
) => util.MaybeAsync<util.SafeParseResult<core.output<T>>>;
151+
152+
export const _safeParseMaybeAsync: (_Err: $ZodErrorClass) => $SafeParseMaybeAsync = (_Err) => (schema, value, _ctx) => {
153+
if (!_asyncObserved.has(schema)) {
154+
const syncCtx: schemas.ParseContextInternal = _ctx ? { ..._ctx, async: false } : { async: false };
155+
let syncResult: schemas.ParsePayload | Promise<schemas.ParsePayload> | undefined;
156+
try {
157+
syncResult = schema._zod.run({ value, issues: [] }, syncCtx);
158+
} catch (e) {
159+
if (!(e instanceof core.$ZodAsyncError)) throw e;
160+
_asyncObserved.add(schema);
161+
}
162+
if (syncResult !== undefined && !(syncResult instanceof Promise)) {
163+
return (
164+
syncResult.issues.length
165+
? {
166+
success: false,
167+
error: new _Err(syncResult.issues.map((iss) => util.finalizeIssue(iss, syncCtx, core.config()))),
168+
}
169+
: { success: true, data: syncResult.value }
170+
) as any;
171+
}
172+
if (syncResult instanceof Promise) _asyncObserved.add(schema);
173+
}
174+
const ctx: schemas.ParseContextInternal = _ctx ? { ..._ctx, async: true } : { async: true };
175+
const ar = schema._zod.run({ value, issues: [] }, ctx);
176+
const finalize = (rr: schemas.ParsePayload) =>
177+
rr.issues.length
178+
? { success: false, error: new _Err(rr.issues.map((iss) => util.finalizeIssue(iss, ctx, core.config()))) }
179+
: { success: true, data: rr.value };
180+
return (ar instanceof Promise ? ar.then(finalize) : finalize(ar)) as any;
181+
};
182+
183+
export const safeParseMaybeAsync: $SafeParseMaybeAsync = /* @__PURE__*/ _safeParseMaybeAsync(errors.$ZodRealError);
184+
96185
// Codec functions
97186
export type $Encode = <T extends schemas.$ZodType>(
98187
schema: T,

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 `transform` / `refine` / checks before the first async step run twice on the async fallback — prefer `parseAsync` for non-idempotent steps. */
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
{
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { expect, test } from "vitest";
2+
import * as z from "zod/v4-mini";
3+
4+
// Mini-side coverage for #5379 — methods are wired the same way on ZodMiniType.
5+
6+
test("ZodMiniType.parseMaybeAsync stays sync when nothing is async", () => {
7+
const result = z.string().parseMaybeAsync("hi");
8+
expect(result instanceof Promise).toBe(false);
9+
expect(result).toBe("hi");
10+
});
11+
12+
test("ZodMiniType.parseMaybeAsync returns a Promise on async refinement", async () => {
13+
const schema = z.string().check(z.refine(async (v) => v.length > 0));
14+
const result = schema.parseMaybeAsync("hi");
15+
expect(result instanceof Promise).toBe(true);
16+
await expect(result).resolves.toBe("hi");
17+
});
18+
19+
test("ZodMiniType.safeParseMaybeAsync sync success / failure", () => {
20+
const success = z.string().safeParseMaybeAsync("hi");
21+
expect(success instanceof Promise).toBe(false);
22+
expect(success).toEqual({ success: true, data: "hi" });
23+
24+
const failure = z.string().safeParseMaybeAsync(42);
25+
expect(failure instanceof Promise).toBe(false);
26+
if (!(failure instanceof Promise)) expect(failure.success).toBe(false);
27+
});

0 commit comments

Comments
 (0)