diff --git a/packages/bench/object-maybe-async.ts b/packages/bench/object-maybe-async.ts new file mode 100644 index 0000000000..5e3252300d --- /dev/null +++ b/packages/bench/object-maybe-async.ts @@ -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(); +} diff --git a/packages/zod/src/v4/classic/parse.ts b/packages/zod/src/v4/classic/parse.ts index 7df7b290da..cc1de51338 100644 --- a/packages/zod/src/v4/classic/parse.ts +++ b/packages/zod/src/v4/classic/parse.ts @@ -19,6 +19,13 @@ export const parseAsync: ( _params?: { callee?: core.util.AnyFunc; Err?: core.$ZodErrorClass } ) => Promise> = /* @__PURE__ */ core._parseAsync(ZodRealError); +export const parseMaybeAsync: ( + schema: T, + value: unknown, + _ctx?: core.ParseContext, + _params?: { callee?: core.util.AnyFunc; Err?: core.$ZodErrorClass } +) => core.util.MaybeAsync> = /* @__PURE__ */ core._parseMaybeAsync(ZodRealError); + export const safeParse: ( schema: T, value: unknown, @@ -32,6 +39,14 @@ export const safeParseAsync: ( _ctx?: core.ParseContext ) => Promise>> = /* @__PURE__ */ core._safeParseAsync(ZodRealError) as any; +export const safeParseMaybeAsync: ( + schema: T, + value: unknown, + _ctx?: core.ParseContext +) => core.util.MaybeAsync>> = /* @__PURE__ */ core._safeParseMaybeAsync( + ZodRealError +) as any; + // Codec functions export const encode: ( schema: T, diff --git a/packages/zod/src/v4/classic/schemas.ts b/packages/zod/src/v4/classic/schemas.ts index eb57e017e0..ba572e4771 100644 --- a/packages/zod/src/v4/classic/schemas.ts +++ b/packages/zod/src/v4/classic/schemas.ts @@ -115,6 +115,13 @@ export interface ZodType< parse(data: unknown, params?: core.ParseContext): core.output; safeParse(data: unknown, params?: core.ParseContext): parse.ZodSafeParseResult>; parseAsync(data: unknown, params?: core.ParseContext): Promise>; + /** 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.util.MaybeAsync>; + /** Safe variant of `parseMaybeAsync`. Same side-effect caveat. */ + safeParseMaybeAsync( + data: unknown, + params?: core.ParseContext + ): core.util.MaybeAsync>>; safeParseAsync( data: unknown, params?: core.ParseContext @@ -235,6 +242,8 @@ export const ZodType: core.$constructor = /*@__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); diff --git a/packages/zod/src/v4/classic/tests/parse-maybe-async.test.ts b/packages/zod/src/v4/classic/tests/parse-maybe-async.test.ts new file mode 100644 index 0000000000..84585d1b02 --- /dev/null +++ b/packages/zod/src/v4/classic/tests/parse-maybe-async.test.ts @@ -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"); +}); diff --git a/packages/zod/src/v4/core/parse.ts b/packages/zod/src/v4/core/parse.ts index aaf2929742..fd06c6b70e 100644 --- a/packages/zod/src/v4/core/parse.ts +++ b/packages/zod/src/v4/core/parse.ts @@ -50,6 +50,58 @@ export const _parseAsync: (_Err: $ZodErrorClass) => $ParseAsync = (_Err) => asyn export const parseAsync: $ParseAsync = /* @__PURE__*/ _parseAsync(errors.$ZodRealError); +export type $ParseMaybeAsync = ( + schema: T, + value: unknown, + _ctx?: schemas.ParseContext, + _params?: { callee?: util.AnyFunc; Err?: $ZodErrorClass } +) => util.MaybeAsync>; + +// 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(); + +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 | 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; + } + // 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; + }; + return ar instanceof Promise ? ar.then(finalize) : finalize(ar); + }; + +export const parseMaybeAsync: $ParseMaybeAsync = /* @__PURE__*/ _parseMaybeAsync(errors.$ZodRealError); + export type $SafeParse = ( schema: T, value: unknown, @@ -93,6 +145,45 @@ export const _safeParseAsync: (_Err: $ZodErrorClass) => $SafeParseAsync = (_Err) export const safeParseAsync: $SafeParseAsync = /* @__PURE__*/ _safeParseAsync(errors.$ZodRealError); +export type $SafeParseMaybeAsync = ( + schema: T, + value: unknown, + _ctx?: schemas.ParseContext +) => util.MaybeAsync>>; + +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 | 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 = ( schema: T, diff --git a/packages/zod/src/v4/mini/parse.ts b/packages/zod/src/v4/mini/parse.ts index d4d1ee2f15..650ce096a7 100644 --- a/packages/zod/src/v4/mini/parse.ts +++ b/packages/zod/src/v4/mini/parse.ts @@ -3,6 +3,8 @@ export { safeParse, parseAsync, safeParseAsync, + parseMaybeAsync, + safeParseMaybeAsync, encode, decode, encodeAsync, diff --git a/packages/zod/src/v4/mini/schemas.ts b/packages/zod/src/v4/mini/schemas.ts index 38978a03d5..2f64991758 100644 --- a/packages/zod/src/v4/mini/schemas.ts +++ b/packages/zod/src/v4/mini/schemas.ts @@ -34,6 +34,13 @@ export interface ZodMiniType< data: unknown, params?: core.ParseContext ): Promise>>; + /** 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): util.MaybeAsync>; + /** Safe variant of `parseMaybeAsync`. Same side-effect caveat. */ + safeParseMaybeAsync( + data: unknown, + params?: core.ParseContext + ): util.MaybeAsync>>; apply(fn: (schema: this) => T): T; } @@ -53,6 +60,9 @@ export const ZodMiniType: core.$constructor = /*@__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( { diff --git a/packages/zod/src/v4/mini/tests/parse-maybe-async.test.ts b/packages/zod/src/v4/mini/tests/parse-maybe-async.test.ts new file mode 100644 index 0000000000..e54698a61e --- /dev/null +++ b/packages/zod/src/v4/mini/tests/parse-maybe-async.test.ts @@ -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); +});