Skip to content

Commit b0dab9a

Browse files
committed
feat(v4): add parseMaybeAsync / safeParseMaybeAsync (#5379)
`parse` is sync-only and throws `$ZodAsyncError` if any validator returns a Promise; `parseAsync` always returns a Promise even when no async work was actually involved. There is no method that returns a Promise only when async validation runs, despite the engine already producing exactly that — `schema._zod.run({…}, ctx)` returns `MaybeAsync<ParsePayload>` and `util.MaybeAsync<T>` is already defined. Add `parseMaybeAsync` and `safeParseMaybeAsync` on `ZodType` (classic) and `ZodMiniType` (mini), backed by new `_parseMaybeAsync` and `_safeParseMaybeAsync` factories in `core/parse.ts`. Both run with `ctx.async = true` (so async validators don't throw) and return the raw payload synchronously when no Promise was produced, otherwise a Promise that awaits and finalizes. `$ZodType['~standard'].validate` previously implemented the same behaviour by calling `safeParse` first and falling through to `safeParseAsync` in a `catch`, parsing the value twice when async work was needed. Refactor it onto `safeParseMaybeAsync` to eliminate the double-parse and the try/catch on the hot path. Test coverage: - classic `parseMaybeAsync` and `safeParseMaybeAsync`: sync success, sync failure, async success, async failure - mini equivalents (sync success/failure, async success) - StandardSchema `validate` integration: sync stays sync, async stays async, double-parse path is gone
1 parent e58ea4d commit b0dab9a

8 files changed

Lines changed: 178 additions & 6 deletions

File tree

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: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,11 @@ export interface ZodType<
116116
parse(data: unknown, params?: core.ParseContext<core.$ZodIssue>): core.output<this>;
117117
safeParse(data: unknown, params?: core.ParseContext<core.$ZodIssue>): parse.ZodSafeParseResult<core.output<this>>;
118118
parseAsync(data: unknown, params?: core.ParseContext<core.$ZodIssue>): Promise<core.output<this>>;
119+
parseMaybeAsync(data: unknown, params?: core.ParseContext<core.$ZodIssue>): core.util.MaybeAsync<core.output<this>>;
120+
safeParseMaybeAsync(
121+
data: unknown,
122+
params?: core.ParseContext<core.$ZodIssue>
123+
): core.util.MaybeAsync<parse.ZodSafeParseResult<core.output<this>>>;
119124
safeParseAsync(
120125
data: unknown,
121126
params?: core.ParseContext<core.$ZodIssue>
@@ -236,6 +241,8 @@ export const ZodType: core.$constructor<ZodType> = /*@__PURE__*/ core.$construct
236241
inst.safeParse = (data, params) => parse.safeParse(inst, data, params);
237242
inst.parseAsync = async (data, params) => parse.parseAsync(inst, data, params, { callee: inst.parseAsync });
238243
inst.safeParseAsync = async (data, params) => parse.safeParseAsync(inst, data, params);
244+
inst.parseMaybeAsync = (data, params) => parse.parseMaybeAsync(inst, data, params, { callee: inst.parseMaybeAsync });
245+
inst.safeParseMaybeAsync = (data, params) => parse.safeParseMaybeAsync(inst, data, params);
239246
inst.spa = inst.safeParseAsync;
240247
inst.encode = (data, params) => parse.encode(inst, data, params);
241248
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: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,30 @@ 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+
export const _parseMaybeAsync: (_Err: $ZodErrorClass) => $ParseMaybeAsync =
61+
(_Err) => (schema, value, _ctx, _params) => {
62+
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())));
66+
util.captureStackTrace(e, _params?.callee);
67+
throw e;
68+
}
69+
return r.value as core.output<typeof schema>;
70+
};
71+
const result = schema._zod.run({ value, issues: [] }, ctx);
72+
return result instanceof Promise ? result.then(finalize) : finalize(result);
73+
};
74+
75+
export const parseMaybeAsync: $ParseMaybeAsync = /* @__PURE__*/ _parseMaybeAsync(errors.$ZodRealError);
76+
5377
export type $SafeParse = <T extends schemas.$ZodType>(
5478
schema: T,
5579
value: unknown,
@@ -93,6 +117,24 @@ export const _safeParseAsync: (_Err: $ZodErrorClass) => $SafeParseAsync = (_Err)
93117

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

120+
export type $SafeParseMaybeAsync = <T extends schemas.$ZodType>(
121+
schema: T,
122+
value: unknown,
123+
_ctx?: schemas.ParseContext<errors.$ZodIssue>
124+
) => util.MaybeAsync<util.SafeParseResult<core.output<T>>>;
125+
126+
export const _safeParseMaybeAsync: (_Err: $ZodErrorClass) => $SafeParseMaybeAsync = (_Err) => (schema, value, _ctx) => {
127+
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;
134+
};
135+
136+
export const safeParseMaybeAsync: $SafeParseMaybeAsync = /* @__PURE__*/ _safeParseMaybeAsync(errors.$ZodRealError);
137+
96138
// Codec functions
97139
export type $Encode = <T extends schemas.$ZodType>(
98140
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, safeParse, safeParseAsync, 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";
@@ -302,12 +302,11 @@ export const $ZodType: core.$constructor<$ZodType> = /*@__PURE__*/ core.$constru
302302
// Lazy initialize ~standard to avoid creating objects for every schema
303303
util.defineLazy(inst, "~standard", () => ({
304304
validate: (value: unknown) => {
305-
try {
306-
const r = safeParse(inst, value);
307-
return r.success ? { value: r.data } : { issues: r.error?.issues };
308-
} catch (_) {
309-
return safeParseAsync(inst, value).then((r) => (r.success ? { value: r.data } : { issues: r.error?.issues }));
305+
const r = safeParseMaybeAsync(inst, value);
306+
if (r instanceof Promise) {
307+
return r.then((rr) => (rr.success ? { value: rr.data } : { issues: rr.error?.issues }));
310308
}
309+
return r.success ? { value: r.data } : { issues: r.error?.issues };
311310
},
312311
vendor: "zod",
313312
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: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ export interface ZodMiniType<
3434
data: unknown,
3535
params?: core.ParseContext<core.$ZodIssue>
3636
): Promise<util.SafeParseResult<core.output<this>>>;
37+
parseMaybeAsync(data: unknown, params?: core.ParseContext<core.$ZodIssue>): util.MaybeAsync<core.output<this>>;
38+
safeParseMaybeAsync(
39+
data: unknown,
40+
params?: core.ParseContext<core.$ZodIssue>
41+
): util.MaybeAsync<util.SafeParseResult<core.output<this>>>;
3742
apply<T>(fn: (schema: this) => T): T;
3843
}
3944

@@ -53,6 +58,9 @@ export const ZodMiniType: core.$constructor<ZodMiniType> = /*@__PURE__*/ core.$c
5358
inst.safeParse = (data, params) => parse.safeParse(inst, data, params);
5459
inst.parseAsync = async (data, params) => parse.parseAsync(inst, data, params, { callee: inst.parseAsync });
5560
inst.safeParseAsync = async (data, params) => parse.safeParseAsync(inst, data, params);
61+
inst.parseMaybeAsync = (data, params) =>
62+
parse.parseMaybeAsync(inst, data, params, { callee: inst.parseMaybeAsync });
63+
inst.safeParseMaybeAsync = (data, params) => parse.safeParseMaybeAsync(inst, data, params);
5664
inst.check = (...checks) => {
5765
return inst.clone(
5866
{
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)