Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions packages/bench/object-maybe-async.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import * as z from "../zod/src/v4/index.js";
import { makeData } from "./benchUtil.js";
import { metabench } from "./metabench.js";

// 1. small flat object — purely sync, exercises ZodObject fastpass directly.
{
const schema = z.object({
string: z.string(),
boolean: z.boolean(),
number: z.number(),
});
const DATA = makeData(1000, () => ({
number: Math.random(),
string: `${Math.random()}`,
boolean: Math.random() > 0.5,
}));
const bench = metabench("flat sync object — safeParse vs safeParseMaybeAsync", {
safeParse() {
for (const _ of DATA) schema.safeParse(_);
},
safeParseMaybeAsync() {
for (const _ of DATA) schema.safeParseMaybeAsync(_);
},
});
await bench.run();
}

// 2. nested + discriminated union — sync but non-trivial; proves the
// fastpass engages end-to-end down the schema tree.
{
const inner = z.object({ tag: z.string(), score: z.number() });
const schema = z.object({
id: z.string(),
nested: z.object({ a: z.string(), b: z.number(), c: z.boolean() }),
items: z.array(inner),
kind: z.discriminatedUnion("type", [
z.object({ type: z.literal("a"), x: z.string() }),
z.object({ type: z.literal("b"), y: z.number() }),
]),
});
const DATA = makeData(500, () => ({
id: `${Math.random()}`,
nested: { a: "x", b: 1, c: true },
items: [
{ tag: "p", score: 1 },
{ tag: "q", score: 2 },
],
kind: Math.random() > 0.5 ? { type: "a", x: "ok" } : { type: "b", y: 1 },
}));
const bench = metabench("nested + discriminated sync — safeParse vs safeParseMaybeAsync", {
safeParse() {
for (const _ of DATA) schema.safeParse(_);
},
safeParseMaybeAsync() {
for (const _ of DATA) schema.safeParseMaybeAsync(_);
},
});
await bench.run();
}

// 3. mostly-sync schema with one deep async refine — quantifies the
// double-walk cost on the async fallback path vs safeParseAsync.
{
const schema = z.object({
id: z.string(),
nested: z.object({ a: z.string(), b: z.number() }),
tail: z.string().refine(async (s) => s.length >= 0),
});
const DATA = makeData(200, () => ({
id: `${Math.random()}`,
nested: { a: "x", b: 1 },
tail: "y",
}));
const bench = metabench("mostly-sync + 1 async refine — safeParseAsync vs safeParseMaybeAsync", {
async safeParseAsync() {
for (const _ of DATA) await schema.safeParseAsync(_);
},
async safeParseMaybeAsync() {
for (const _ of DATA) await schema.safeParseMaybeAsync(_);
},
});
await bench.run();
}
15 changes: 15 additions & 0 deletions packages/zod/src/v4/classic/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ export const parseAsync: <T extends core.$ZodType>(
_params?: { callee?: core.util.AnyFunc; Err?: core.$ZodErrorClass }
) => Promise<core.output<T>> = /* @__PURE__ */ core._parseAsync(ZodRealError);

export const parseMaybeAsync: <T extends core.$ZodType>(
schema: T,
value: unknown,
_ctx?: core.ParseContext<core.$ZodIssue>,
_params?: { callee?: core.util.AnyFunc; Err?: core.$ZodErrorClass }
) => core.util.MaybeAsync<core.output<T>> = /* @__PURE__ */ core._parseMaybeAsync(ZodRealError);

export const safeParse: <T extends core.$ZodType>(
schema: T,
value: unknown,
Expand All @@ -32,6 +39,14 @@ export const safeParseAsync: <T extends core.$ZodType>(
_ctx?: core.ParseContext<core.$ZodIssue>
) => Promise<ZodSafeParseResult<core.output<T>>> = /* @__PURE__ */ core._safeParseAsync(ZodRealError) as any;

export const safeParseMaybeAsync: <T extends core.$ZodType>(
schema: T,
value: unknown,
_ctx?: core.ParseContext<core.$ZodIssue>
) => core.util.MaybeAsync<ZodSafeParseResult<core.output<T>>> = /* @__PURE__ */ core._safeParseMaybeAsync(
ZodRealError
) as any;

// Codec functions
export const encode: <T extends core.$ZodType>(
schema: T,
Expand Down
9 changes: 9 additions & 0 deletions packages/zod/src/v4/classic/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,13 @@ export interface ZodType<
parse(data: unknown, params?: core.ParseContext<core.$ZodIssue>): core.output<this>;
safeParse(data: unknown, params?: core.ParseContext<core.$ZodIssue>): parse.ZodSafeParseResult<core.output<this>>;
parseAsync(data: unknown, params?: core.ParseContext<core.$ZodIssue>): Promise<core.output<this>>;
/** 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. Once a schema has been observed as async, subsequent calls skip the sync attempt and return a Promise even for inputs that would resolve synchronously (matters for unions or preprocess-gated async branches). */
parseMaybeAsync(data: unknown, params?: core.ParseContext<core.$ZodIssue>): core.util.MaybeAsync<core.output<this>>;
/** Safe variant of `parseMaybeAsync`. Same side-effect caveat. */
safeParseMaybeAsync(
data: unknown,
params?: core.ParseContext<core.$ZodIssue>
): core.util.MaybeAsync<parse.ZodSafeParseResult<core.output<this>>>;
safeParseAsync(
data: unknown,
params?: core.ParseContext<core.$ZodIssue>
Expand Down Expand Up @@ -235,6 +242,8 @@ export const ZodType: core.$constructor<ZodType> = /*@__PURE__*/ core.$construct
inst.safeParse = (data, params) => parse.safeParse(inst, data, params);
inst.parseAsync = async (data, params) => parse.parseAsync(inst, data, params, { callee: inst.parseAsync });
inst.safeParseAsync = async (data, params) => parse.safeParseAsync(inst, data, params);
inst.parseMaybeAsync = (data, params) => parse.parseMaybeAsync(inst, data, params, { callee: inst.parseMaybeAsync });
inst.safeParseMaybeAsync = (data, params) => parse.safeParseMaybeAsync(inst, data, params);
inst.spa = inst.safeParseAsync;
inst.encode = (data, params) => parse.encode(inst, data, params);
inst.decode = (data, params) => parse.decode(inst, data, params);
Expand Down
57 changes: 57 additions & 0 deletions packages/zod/src/v4/classic/tests/parse-maybe-async.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { expect, test } from "vitest";
import * as z from "zod/v4";

// Tests for #5379.

test("parseMaybeAsync returns the value sync when no async work is involved", () => {
const schema = z.string();
const result = schema.parseMaybeAsync("hello");
expect(result instanceof Promise).toBe(false);
expect(result).toBe("hello");
});

test("parseMaybeAsync returns a Promise when an async refinement is encountered", async () => {
const schema = z.string().refine(async (v) => v.length > 0);
const result = schema.parseMaybeAsync("hello");
expect(result instanceof Promise).toBe(true);
await expect(result).resolves.toBe("hello");
});

test("parseMaybeAsync throws sync on validation failure with no async work", () => {
const schema = z.string();
expect(() => schema.parseMaybeAsync(42)).toThrow(z.ZodError);
});

test("parseMaybeAsync rejects with ZodError on async validation failure", async () => {
const schema = z.string().refine(async (v) => v.length > 5, { message: "too short" });
const result = schema.parseMaybeAsync("hi");
expect(result instanceof Promise).toBe(true);
await expect(result).rejects.toThrow(z.ZodError);
});

test("safeParseMaybeAsync mirrors parseMaybeAsync but wraps the result", () => {
const sync = z.string().safeParseMaybeAsync("hi");
expect(sync instanceof Promise).toBe(false);
expect(sync).toEqual({ success: true, data: "hi" });

const failure = z.string().safeParseMaybeAsync(42);
expect(failure instanceof Promise).toBe(false);
if (!(failure instanceof Promise)) expect(failure.success).toBe(false);
});

test("safeParseMaybeAsync awaits async refinements (success)", async () => {
const schema = z.string().refine(async (v) => v.length > 0);
const result = schema.safeParseMaybeAsync("hi");
expect(result instanceof Promise).toBe(true);
const settled = await result;
expect(settled).toEqual({ success: true, data: "hi" });
});

test("safeParseMaybeAsync awaits async refinements (failure)", async () => {
const schema = z.string().refine(async (v) => v.length > 5, { message: "too short" });
const result = schema.safeParseMaybeAsync("hi");
expect(result instanceof Promise).toBe(true);
const settled = await result;
expect(settled.success).toBe(false);
if (!settled.success) expect(settled.error.issues[0].message).toBe("too short");
});
91 changes: 91 additions & 0 deletions packages/zod/src/v4/core/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,58 @@ export const _parseAsync: (_Err: $ZodErrorClass) => $ParseAsync = (_Err) => asyn

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

export type $ParseMaybeAsync = <T extends schemas.$ZodType>(
schema: T,
value: unknown,
_ctx?: schemas.ParseContext<errors.$ZodIssue>,
_params?: { callee?: util.AnyFunc; Err?: $ZodErrorClass }
) => util.MaybeAsync<core.output<T>>;

// Async-observed schemas skip the sync attempt. Sticky per schema instance: once added,
// every subsequent call routes async — including inputs that would have resolved sync
// (e.g. sync branch of z.union([sync, async]), preprocess-gated async). Inlined in both
// factories below: a shared `{ ctx, result }` helper regresses the sync path ~12x.
const _asyncObserved = new WeakSet<schemas.$ZodType>();
Comment thread
dokson marked this conversation as resolved.

export const _parseMaybeAsync: (_Err: $ZodErrorClass) => $ParseMaybeAsync =
(_Err) => (schema, value, _ctx, _params) => {
const ErrCls = _params?.Err ?? _Err;
if (!_asyncObserved.has(schema)) {
// Sync first: ZodObject's JIT fastpass requires ctx.async === false.
const syncCtx: schemas.ParseContextInternal = _ctx ? { ..._ctx, async: false } : { async: false };
let syncResult: schemas.ParsePayload | Promise<schemas.ParsePayload> | undefined;
try {
syncResult = schema._zod.run({ value, issues: [] }, syncCtx);
} catch (e) {
if (!(e instanceof core.$ZodAsyncError)) throw e;
_asyncObserved.add(schema);
}
if (syncResult !== undefined && !(syncResult instanceof Promise)) {
if (syncResult.issues.length) {
const e = new ErrCls(syncResult.issues.map((iss) => util.finalizeIssue(iss, syncCtx, core.config())));
util.captureStackTrace(e, _params?.callee);
throw e;
}
return syncResult.value as core.output<typeof schema>;
}
// Defensive: schema returned a Promise without throwing — treat as async-capable.
if (syncResult instanceof Promise) _asyncObserved.add(schema);
}
const ctx: schemas.ParseContextInternal = _ctx ? { ..._ctx, async: true } : { async: true };
const ar = schema._zod.run({ value, issues: [] }, ctx);
const finalize = (rr: schemas.ParsePayload) => {
if (rr.issues.length) {
const e = new ErrCls(rr.issues.map((iss) => util.finalizeIssue(iss, ctx, core.config())));
util.captureStackTrace(e, _params?.callee);
throw e;
}
return rr.value as core.output<typeof schema>;
};
return ar instanceof Promise ? ar.then(finalize) : finalize(ar);
};
Comment thread
dokson marked this conversation as resolved.

export const parseMaybeAsync: $ParseMaybeAsync = /* @__PURE__*/ _parseMaybeAsync(errors.$ZodRealError);

export type $SafeParse = <T extends schemas.$ZodType>(
schema: T,
value: unknown,
Expand Down Expand Up @@ -93,6 +145,45 @@ export const _safeParseAsync: (_Err: $ZodErrorClass) => $SafeParseAsync = (_Err)

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

export type $SafeParseMaybeAsync = <T extends schemas.$ZodType>(
schema: T,
value: unknown,
_ctx?: schemas.ParseContext<errors.$ZodIssue>
) => util.MaybeAsync<util.SafeParseResult<core.output<T>>>;

export const _safeParseMaybeAsync: (_Err: $ZodErrorClass) => $SafeParseMaybeAsync = (_Err) => (schema, value, _ctx) => {
if (!_asyncObserved.has(schema)) {
const syncCtx: schemas.ParseContextInternal = _ctx ? { ..._ctx, async: false } : { async: false };
let syncResult: schemas.ParsePayload | Promise<schemas.ParsePayload> | undefined;
try {
syncResult = schema._zod.run({ value, issues: [] }, syncCtx);
} catch (e) {
if (!(e instanceof core.$ZodAsyncError)) throw e;
_asyncObserved.add(schema);
}
if (syncResult !== undefined && !(syncResult instanceof Promise)) {
return (
syncResult.issues.length
? {
success: false,
error: new _Err(syncResult.issues.map((iss) => util.finalizeIssue(iss, syncCtx, core.config()))),
}
: { success: true, data: syncResult.value }
) as any;
}
if (syncResult instanceof Promise) _asyncObserved.add(schema);
}
const ctx: schemas.ParseContextInternal = _ctx ? { ..._ctx, async: true } : { async: true };
const ar = schema._zod.run({ value, issues: [] }, ctx);
const finalize = (rr: schemas.ParsePayload) =>
rr.issues.length
? { success: false, error: new _Err(rr.issues.map((iss) => util.finalizeIssue(iss, ctx, core.config()))) }
: { success: true, data: rr.value };
return (ar instanceof Promise ? ar.then(finalize) : finalize(ar)) as any;
};

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

// Codec functions
export type $Encode = <T extends schemas.$ZodType>(
schema: T,
Expand Down
2 changes: 2 additions & 0 deletions packages/zod/src/v4/mini/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ export {
safeParse,
parseAsync,
safeParseAsync,
parseMaybeAsync,
safeParseMaybeAsync,
encode,
decode,
encodeAsync,
Expand Down
10 changes: 10 additions & 0 deletions packages/zod/src/v4/mini/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ export interface ZodMiniType<
data: unknown,
params?: core.ParseContext<core.$ZodIssue>
): Promise<util.SafeParseResult<core.output<this>>>;
/** 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. Once a schema has been observed as async, subsequent calls skip the sync attempt and return a Promise even for inputs that would resolve synchronously (matters for unions or preprocess-gated async branches). */
parseMaybeAsync(data: unknown, params?: core.ParseContext<core.$ZodIssue>): util.MaybeAsync<core.output<this>>;
/** Safe variant of `parseMaybeAsync`. Same side-effect caveat. */
safeParseMaybeAsync(
data: unknown,
params?: core.ParseContext<core.$ZodIssue>
): util.MaybeAsync<util.SafeParseResult<core.output<this>>>;
apply<T>(fn: (schema: this) => T): T;
}

Expand All @@ -53,6 +60,9 @@ export const ZodMiniType: core.$constructor<ZodMiniType> = /*@__PURE__*/ core.$c
inst.safeParse = (data, params) => parse.safeParse(inst, data, params);
inst.parseAsync = async (data, params) => parse.parseAsync(inst, data, params, { callee: inst.parseAsync });
inst.safeParseAsync = async (data, params) => parse.safeParseAsync(inst, data, params);
inst.parseMaybeAsync = (data, params) =>
parse.parseMaybeAsync(inst, data, params, { callee: inst.parseMaybeAsync });
inst.safeParseMaybeAsync = (data, params) => parse.safeParseMaybeAsync(inst, data, params);
inst.check = (...checks) => {
return inst.clone(
{
Expand Down
27 changes: 27 additions & 0 deletions packages/zod/src/v4/mini/tests/parse-maybe-async.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { expect, test } from "vitest";
import * as z from "zod/v4-mini";

// Mini-side coverage for #5379 — methods are wired the same way on ZodMiniType.

test("ZodMiniType.parseMaybeAsync stays sync when nothing is async", () => {
const result = z.string().parseMaybeAsync("hi");
expect(result instanceof Promise).toBe(false);
expect(result).toBe("hi");
});

test("ZodMiniType.parseMaybeAsync returns a Promise on async refinement", async () => {
const schema = z.string().check(z.refine(async (v) => v.length > 0));
const result = schema.parseMaybeAsync("hi");
expect(result instanceof Promise).toBe(true);
await expect(result).resolves.toBe("hi");
});

test("ZodMiniType.safeParseMaybeAsync sync success / failure", () => {
const success = z.string().safeParseMaybeAsync("hi");
expect(success instanceof Promise).toBe(false);
expect(success).toEqual({ success: true, data: "hi" });

const failure = z.string().safeParseMaybeAsync(42);
expect(failure instanceof Promise).toBe(false);
if (!(failure instanceof Promise)) expect(failure.success).toBe(false);
});
Loading