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 3e052fe3e1..6f925f70af 100644 --- a/packages/zod/src/v4/classic/schemas.ts +++ b/packages/zod/src/v4/classic/schemas.ts @@ -116,6 +116,11 @@ 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>; + parseMaybeAsync(data: unknown, params?: core.ParseContext): core.util.MaybeAsync>; + safeParseMaybeAsync( + data: unknown, + params?: core.ParseContext + ): core.util.MaybeAsync>>; safeParseAsync( data: unknown, params?: core.ParseContext @@ -236,6 +241,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..002f91b6e7 --- /dev/null +++ b/packages/zod/src/v4/classic/tests/parse-maybe-async.test.ts @@ -0,0 +1,72 @@ +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"); +}); + +// Standard Schema integration: validate() now uses safeParseMaybeAsync +// internally, so a fully-sync schema must NOT return a Promise. +test("StandardSchema validate is sync when schema has no async work", () => { + const schema = z.string(); + const validated = schema["~standard"].validate("hi"); + expect(validated instanceof Promise).toBe(false); +}); + +test("StandardSchema validate is async when schema has async refinements", async () => { + const schema = z.string().refine(async (v) => v.length > 0); + const validated = schema["~standard"].validate("hi"); + expect(validated instanceof Promise).toBe(true); + await expect(validated).resolves.toEqual({ value: "hi" }); +}); diff --git a/packages/zod/src/v4/core/parse.ts b/packages/zod/src/v4/core/parse.ts index aaf2929742..6742a9f9b8 100644 --- a/packages/zod/src/v4/core/parse.ts +++ b/packages/zod/src/v4/core/parse.ts @@ -50,6 +50,30 @@ 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>; + +export const _parseMaybeAsync: (_Err: $ZodErrorClass) => $ParseMaybeAsync = + (_Err) => (schema, value, _ctx, _params) => { + const ctx: schemas.ParseContextInternal = _ctx ? { ..._ctx, async: true } : { async: true }; + const finalize = (r: schemas.ParsePayload) => { + if (r.issues.length) { + const e = new (_params?.Err ?? _Err)(r.issues.map((iss) => util.finalizeIssue(iss, ctx, core.config()))); + util.captureStackTrace(e, _params?.callee); + throw e; + } + return r.value as core.output; + }; + const result = schema._zod.run({ value, issues: [] }, ctx); + return result instanceof Promise ? result.then(finalize) : finalize(result); + }; + +export const parseMaybeAsync: $ParseMaybeAsync = /* @__PURE__*/ _parseMaybeAsync(errors.$ZodRealError); + export type $SafeParse = ( schema: T, value: unknown, @@ -93,6 +117,24 @@ 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) => { + const ctx: schemas.ParseContextInternal = _ctx ? { ..._ctx, async: true } : { async: true }; + const finalize = (r: schemas.ParsePayload) => + r.issues.length + ? { success: false, error: new _Err(r.issues.map((iss) => util.finalizeIssue(iss, ctx, core.config()))) } + : { success: true, data: r.value }; + const result = schema._zod.run({ value, issues: [] }, ctx); + return (result instanceof Promise ? result.then(finalize) : finalize(result)) as any; +}; + +export const safeParseMaybeAsync: $SafeParseMaybeAsync = /* @__PURE__*/ _safeParseMaybeAsync(errors.$ZodRealError); + // Codec functions export type $Encode = ( schema: T, diff --git a/packages/zod/src/v4/core/schemas.ts b/packages/zod/src/v4/core/schemas.ts index 3210a89cfe..571286b797 100644 --- a/packages/zod/src/v4/core/schemas.ts +++ b/packages/zod/src/v4/core/schemas.ts @@ -4,7 +4,7 @@ import * as core from "./core.js"; import { Doc } from "./doc.js"; import type * as errors from "./errors.js"; import type * as JSONSchema from "./json-schema.js"; -import { parse, parseAsync, safeParse, safeParseAsync } from "./parse.js"; +import { parse, parseAsync, safeParseMaybeAsync } from "./parse.js"; import * as regexes from "./regexes.js"; import type { StandardSchemaV1 } from "./standard-schema.js"; import type { ProcessParams, ToJSONSchemaContext } from "./to-json-schema.js"; @@ -302,12 +302,11 @@ export const $ZodType: core.$constructor<$ZodType> = /*@__PURE__*/ core.$constru // Lazy initialize ~standard to avoid creating objects for every schema util.defineLazy(inst, "~standard", () => ({ validate: (value: unknown) => { - try { - const r = safeParse(inst, value); - return r.success ? { value: r.data } : { issues: r.error?.issues }; - } catch (_) { - return safeParseAsync(inst, value).then((r) => (r.success ? { value: r.data } : { issues: r.error?.issues })); + const r = safeParseMaybeAsync(inst, value); + if (r instanceof Promise) { + return r.then((rr) => (rr.success ? { value: rr.data } : { issues: rr.error?.issues })); } + return r.success ? { value: r.data } : { issues: r.error?.issues }; }, vendor: "zod", version: 1 as const, 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..dc26587438 100644 --- a/packages/zod/src/v4/mini/schemas.ts +++ b/packages/zod/src/v4/mini/schemas.ts @@ -34,6 +34,11 @@ export interface ZodMiniType< data: unknown, params?: core.ParseContext ): Promise>>; + parseMaybeAsync(data: unknown, params?: core.ParseContext): util.MaybeAsync>; + safeParseMaybeAsync( + data: unknown, + params?: core.ParseContext + ): util.MaybeAsync>>; apply(fn: (schema: this) => T): T; } @@ -53,6 +58,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); +});