diff --git a/packages/zod/src/v4/classic/tests/error-utils.test.ts b/packages/zod/src/v4/classic/tests/error-utils.test.ts index 65011690b..21caf5fc5 100644 --- a/packages/zod/src/v4/classic/tests/error-utils.test.ts +++ b/packages/zod/src/v4/classic/tests/error-utils.test.ts @@ -79,7 +79,7 @@ test(".flatten()", () => { }); test("custom .flatten()", () => { - type ErrorType = { message: string; code: number }; + type ErrorType = { message: string | { key: string; values?: object }; code: number }; const flattened = parsed.error!.flatten((iss) => ({ message: iss.message, code: 1234 })); expectTypeOf(flattened).toMatchTypeOf<{ formErrors: ErrorType[]; @@ -159,7 +159,7 @@ test(".format()", () => { }); test("custom .format()", () => { - type ErrorType = { message: string; code: number }; + type ErrorType = { message: string | { key: string; values?: object }; code: number }; const formatted = parsed.error!.format((iss) => ({ message: iss.message, code: 1234 })); expectTypeOf(formatted).toMatchTypeOf<{ _errors: ErrorType[]; @@ -252,7 +252,7 @@ test("all errors", () => { } `); - expect(z.core.flattenError(r2.error!, (iss) => iss.message.toUpperCase())).toMatchInlineSnapshot(` + expect(z.core.flattenError(r2.error!, (iss) => (iss.message as string).toUpperCase())).toMatchInlineSnapshot(` { "fieldErrors": { "a": [ @@ -296,7 +296,7 @@ test("all errors", () => { `); // Test mapping - const f1 = z.core.flattenError(r2.error!, (i: z.ZodIssue) => i.message.length); + const f1 = z.core.flattenError(r2.error!, (i: z.ZodIssue) => (i.message as string).length); expect(f1).toMatchInlineSnapshot(` { "fieldErrors": { diff --git a/packages/zod/src/v4/classic/tests/error.test.ts b/packages/zod/src/v4/classic/tests/error.test.ts index d884b7e46..e7b45a393 100644 --- a/packages/zod/src/v4/classic/tests/error.test.ts +++ b/packages/zod/src/v4/classic/tests/error.test.ts @@ -709,3 +709,30 @@ test("error serialization", () => { `); } }); + +test("error with object type message", () => { + const schema = z.object({ + name: z.string().min(5, { error: () => ({ message: { key: "test", values: { foo: "bar" } } }) }), + }); + + const obj = { + name: "tes", + }; + + const result = schema.safeParse(obj); + expect(result.success).toBe(false); + expect(result.error).toMatchInlineSnapshot(` + [ZodError: [ + { + "origin": "string", + "code": "too_small", + "minimum": 5, + "inclusive": true, + "path": [ + "name" + ], + "message": "{\\"key\\":\\"test\\",\\"values\\":{\\"foo\\":\\"bar\\"}}" + } + ]] + `); +}); diff --git a/packages/zod/src/v4/core/errors.ts b/packages/zod/src/v4/core/errors.ts index 4d8465606..f74d4aa55 100644 --- a/packages/zod/src/v4/core/errors.ts +++ b/packages/zod/src/v4/core/errors.ts @@ -3,6 +3,9 @@ import { $constructor } from "./core.js"; import type { $ZodType } from "./schemas.js"; import type { StandardSchemaV1 } from "./standard-schema.js"; import * as util from "./util.js"; +import { toMessageString } from "./util.js"; + +export type $ZodIssueMessage = string | { key: string; values?: object }; /////////////////////////// //// base type //// @@ -11,7 +14,7 @@ export interface $ZodIssueBase { readonly code?: string; readonly input?: unknown; readonly path: PropertyKey[]; - readonly message: string; + readonly message: $ZodIssueMessage; } //////////////////////////////// @@ -174,7 +177,7 @@ export type $ZodRawIssue = $ZodInternalIssu export interface $ZodErrorMap { // biome-ignore lint: - (issue: $ZodRawIssue): { message: string } | string | undefined | null; + (issue: $ZodRawIssue): { message: $ZodIssueMessage } | string | undefined | null; } //////////////////////// ERROR CLASS //////////////////////// @@ -226,7 +229,7 @@ type _FlattenedError = { export function flattenError(error: $ZodError): _FlattenedError; export function flattenError(error: $ZodError, mapper?: (issue: $ZodIssue) => U): _FlattenedError; -export function flattenError(error: $ZodError, mapper = (issue: $ZodIssue) => issue.message): any { +export function flattenError(error: $ZodError, mapper = (issue: $ZodIssue) => toMessageString(issue.message)): any { const fieldErrors: any = {}; const formErrors: any[] = []; for (const sub of error.issues) { @@ -258,7 +261,7 @@ export function formatError(error: $ZodError, _mapper?: any) { const mapper: (issue: $ZodIssue) => any = _mapper || function (issue: $ZodIssue) { - return issue.message; + return toMessageString(issue.message); }; const fieldErrors: $ZodFormattedError = { _errors: [] } as any; const processError = (error: { issues: $ZodIssue[] }) => { @@ -311,7 +314,7 @@ export function treeifyError(error: $ZodError, _mapper?: any) { const mapper: (issue: $ZodIssue) => any = _mapper || function (issue: $ZodIssue) { - return issue.message; + return toMessageString(issue.message); }; const result: $ZodErrorTree = { errors: [] } as any; const processError = (error: { issues: $ZodIssue[] }, path: PropertyKey[] = []) => { @@ -414,7 +417,8 @@ export function prettifyError(error: StandardSchemaV1.FailureResult): string { // Process each issue for (const issue of issues) { - lines.push(`✖ ${issue.message}`); + const m = toMessageString(issue.message); + lines.push(`✖ ${m}`); if (issue.path?.length) lines.push(` → at ${toDotPath(issue.path)}`); } diff --git a/packages/zod/src/v4/core/standard-schema.ts b/packages/zod/src/v4/core/standard-schema.ts index d88d0d713..ec17bca83 100644 --- a/packages/zod/src/v4/core/standard-schema.ts +++ b/packages/zod/src/v4/core/standard-schema.ts @@ -1,3 +1,5 @@ +import type { $ZodIssueMessage } from "./errors.js"; + /** The Standard Schema interface. */ export interface StandardSchemaV1 { /** The Standard Schema properties. */ @@ -37,7 +39,7 @@ export declare namespace StandardSchemaV1 { /** The issue interface of the failure output. */ export interface Issue { /** The error message of the issue. */ - readonly message: string; + readonly message: $ZodIssueMessage; /** The path of the issue, if any. */ readonly path?: ReadonlyArray | undefined; } diff --git a/packages/zod/src/v4/core/util.ts b/packages/zod/src/v4/core/util.ts index 5badbd22a..7297ab2cc 100644 --- a/packages/zod/src/v4/core/util.ts +++ b/packages/zod/src/v4/core/util.ts @@ -1,6 +1,7 @@ import type * as checks from "./checks.js"; import type { $ZodConfig } from "./core.js"; import type * as errors from "./errors.js"; +import type { $ZodIssueMessage } from "./errors.js"; import type * as schemas from "./schemas.js"; // json @@ -786,8 +787,11 @@ export function prefixIssues(path: PropertyKey, issues: errors.$ZodRawIssue[]): }); } -export function unwrapMessage(message: string | { message: string } | undefined | null): string | undefined { - return typeof message === "string" ? message : message?.message; +export function unwrapMessage(message: string | { message: $ZodIssueMessage } | undefined | null): string | undefined { + if (message === undefined) { + return undefined; + } + return typeof message === "string" ? message : toMessageString(message?.message || ""); } export function finalizeIssue( @@ -908,3 +912,11 @@ export function uint8ArrayToHex(bytes: Uint8Array): string { export abstract class Class { constructor(..._args: any[]) {} } + +export function toMessageString(message: $ZodIssueMessage): string { + if (typeof message === "string") { + return message; + } else { + return JSON.stringify(message); + } +}