6. Error Formatting & 49 i18n Locales

Zod v4 overhauls error customization and formatting from the ground up. Fragmented v3 options (message, invalid_type_error, required_error, and errorMap) are consolidated into a single { error } parameter, backed by a deterministic 4-level precedence chain, tree-shakable top-level formatters (z.prettifyError, z.treeifyError, z.flattenError), and 49+ built-in i18n locales.

06_errors_and_locales_1
Zod v4 4-Level Error Precedence Chain & Output Formatters
Source Code
from drawlib.canvas import save, setup
from drawlib.icons import phosphor
from drawlib.lines import line
from drawlib.shapes import rectangle
from drawlib.smartarts import Panel
from drawlib.styles import Colors, Styles
from drawlib.text import text

setup(width=160, height=76)

text(
    (80, 71.5),
    "Zod v4 Error Resolution Precedence & Formatting Pipeline",
    style=Styles.PrimaryBold.patch(text_size=13.5),
)

# Left panel: 4-Level Precedence Chain (Highest -> Lowest)
Panel(
    "1. 4-Level Error Map Precedence (First String Wins)",
    style=Styles.Neutral.patch(shape_fill_color=Colors.White),
    header_style=Styles.PrimaryNeutral,
    text_style=Styles.PrimaryBold.patch(text_size=11.0),
    header_align="center",
).draw((6, 6), width=74, height=60)

levels = [
    ("P1: Per-Parse { error }", "schema.safeParse(data, { error })", Styles.PrimaryFlat, Styles.WhiteBold, Styles.White),
    ("P2: Per-Schema { error }", "z.string({ error: (iss) => ... })", Styles.PrimaryNeutral, Styles.PrimaryBold, Styles.Neutral),
    ("P3: Global customError", "z.config({ customError: (iss) => ... })", Styles.Neutral, Styles.PrimaryBold, Styles.Neutral),
    ("P4: Locale Error Map (49+)", "z.config(z.locales.ja())", Styles.SecondaryNeutral, Styles.SecondaryBold, Styles.Neutral),
]

for idx, (title_str, code_str, box_style, t_style, sub_style) in enumerate(levels):
    cy = 50.0 - idx * 12.2
    rectangle((43, cy), width=64, height=9.0, style=box_style.patch(shape_r=1.5))
    text((43, cy + 1.8), title_str, style=t_style.patch(text_size=10.5))
    text((43, cy - 2.0), code_str, style=sub_style.patch(text_size=10.0))
    if idx < 3:
        line((43, cy - 4.5), (43, cy - 7.7), arrow_head="->", style=Styles.NeutralBold)

# Center node: $ZodError instance
rectangle((97, 36), width=20, height=18, style=Styles.AccentFlat.patch(shape_r=2.0))
phosphor.warning_octagon((97, 40.5), width=5.5, style=Styles.White)
text((97, 34.0), "ZodError", style=Styles.WhiteBold.patch(text_size=11.5))
text((97, 30.0), ".issues[]", style=Styles.White.patch(text_size=10.0))

line((80, 36), (87, 36), arrow_head="->", style=Styles.PrimaryBold)

# Right panel: Output Formatters
Panel(
    "2. Tree-Shakable Formatters",
    style=Styles.Neutral.patch(shape_fill_color=Colors.White),
    header_style=Styles.PrimaryNeutral,
    text_style=Styles.PrimaryBold.patch(text_size=11.0),
    header_align="center",
).draw((114, 6), width=40, height=60)

formatters = [
    ("z.prettifyError(err)", "CLI / Log String", Styles.PrimaryNeutral),
    ("z.treeifyError(err)", "Nested Form Tree", Styles.PrimaryFlat),
    ("z.flattenError(err)", "1-Level Field Map", Styles.Neutral),
    ("z.formatError(err)", "Legacy _errors Tree", Styles.Neutral),
]

for idx, (fn_name, desc_str, f_style) in enumerate(formatters):
    cy = 50.0 - idx * 12.2
    is_flat = f_style == Styles.PrimaryFlat
    rectangle((134, cy), width=33, height=9.0, style=f_style.patch(shape_r=1.5))
    text(
        (134, cy + 1.8),
        fn_name,
        style=(Styles.WhiteBold if is_flat else Styles.PrimaryBold).patch(text_size=10.5),
    )
    text(
        (134, cy - 2.0),
        desc_str,
        style=(Styles.White if is_flat else Styles.Neutral).patch(text_size=10.0),
    )

line((107, 36), (114, 36), arrow_head="->", style=Styles.PrimaryBold)

save()

1. Unified { error } Customization

In Zod v3, error customization was split across multiple overlapping APIs (message, invalid_type_error, required_error, and errorMap). Zod v4 replaces all of them with a single error option accepted by every schema constructor, check method, and .refine() call.

The error property accepts either:

  • A static string: { error: "Invalid email address" }
  • An error map function (iss: $ZodRawIssue) => string | undefined: Returning undefined yields control to the next error map in the precedence chain.
import * as z from "zod";

// 1. Static string error on schema creation or check method
const Username = z
  .string({ error: "Username must be a text string" })
  .min(3, { error: "Username must be at least 3 characters" });

// 2. Dynamic error map function inspecting the raw issue
//    Replaces both `required_error` and `invalid_type_error` from Zod v3!
const AgeSchema = z.number({
  error: (iss) => {
    if (iss.input === undefined) {
      return "Age is required";
    }
    if (iss.code === "invalid_type") {
      return `Expected a number, but received ${typeof iss.input}`;
    }
    return undefined; // Fall through to global / locale error map
  },
}).min(18, {
  error: (iss) => `Must be at least ${iss.minimum} years old (got ${iss.input})`,
});

Consolidated $ZodIssue Codes in v4

Zod v4 streamlines issue types into a clean discriminated union on issue.code:

Issue Code (issue.code) Key Metadata Properties Triggered By
"invalid_type" expected, input Base type checks (z.string(), z.number(), z.object(), etc.)
"too_small" origin, minimum, inclusive, exact .min(), .gt(), .gte(), .nonempty(), .minLength()
"too_big" origin, maximum, inclusive, exact .max(), .lt(), .lte(), .maxLength()
"invalid_format" format, pattern, prefix, suffix, includes z.email(), z.uuid(), .regex(), .startsWith(), z.iso.*
"invalid_value" values, input z.literal(), z.enum(), z.nativeEnum() (merges v3 enum/literal issues)
"not_multiple_of" divisor, input .multipleOf(), .step()
"unrecognized_keys" keys, input z.strictObject() encountering extra keys
"invalid_union" errors, discriminator, matches z.union(), z.discriminatedUnion(), z.xor()
"invalid_key" / "invalid_element" origin, issues, key z.record(), z.map(), z.set() validation failures
"custom" params, input .refine(), .superRefine(), .check()

2. The 4-Level Error Precedence Chain

When validation fails, Zod resolves the human-readable issue.message by evaluating error maps in strict priority order. The first map that returns a non-null string (or { message: string }) wins:

  1. Per-parse { error } — Passed directly to .parse(data, { error }) or .safeParse(data, { error }). Notice that in Zod v4, per-parse overrides take highest priority (overriding schema-level messages when you need request-scoped localization).
  2. Per-schema / per-check { error } — Defined on the schema or check that originated the issue.
  3. Global customError — Configured via z.config({ customError: (iss) => ... }).
  4. Locale localeError — Configured via z.config(z.locales.ja()) (falling back to the default English locale if unset).
import * as z from "zod";
import { ja } from "zod/locales";

// Level 4: Configure Japanese as the baseline locale
z.config(ja());

// Level 3: Configure a global custom override for specific issue codes
z.config({
  customError: (iss) => {
    if (iss.code === "unrecognized_keys") {
      return `Unknown fields are not allowed: ${iss.keys.join(", ")}`;
    }
    return undefined; // Defer to Level 4 (Japanese locale)
  },
});

// Level 2: Schema-level custom error
const CodeSchema = z.string().min(5, { error: "Code is too short (min 5)" });

// Uses Level 2 -> "Code is too short (min 5)"
CodeSchema.safeParse("abc");

// Level 1: Per-parse override wins over everything
CodeSchema.safeParse("abc", {
  error: () => "Request-scoped override message",
});

3. Tree-Shakable Error Formatters

In Zod v3, .format() and .flatten() were attached to ZodError.prototype, preventing bundlers from tree-shaking them even in applications that only read err.issues. Zod v4 moves all error formatters to top-level functions that work across zod, zod/mini, and zod/v4/core.

06_errors_and_locales_2
Mapping Flat ZodError Issues into Nested Properties & Array Items via z.treeifyError()

z.prettifyError(err) — Human-Readable CLI & Log Output

z.prettifyError(err) converts any ZodError (or any Standard Schema FailureResult) into a clean, multi-line summary sorted by path depth, using toDotPath() to format property chains and array indices:

import * as z from "zod";

const TeamSchema = z.object({
  teamName: z.string().min(3),
  members: z.array(
    z.object({
      email: z.email(),
      role: z.enum(["admin", "member"]),
    })
  ),
});

const result = TeamSchema.safeParse({
  teamName: "A",
  members: [{ email: "not-an-email", role: "guest" }],
});

if (!result.success) {
  console.log(z.prettifyError(result.error));
  /* Output:
  ✖ Too small: expected string to have >=3 characters
    → at teamName
  ✖ Invalid email address
    → at members[0].email
  ✖ Invalid option: expected one of "admin"|"member"
    → at members[0].role
  */
}

z.treeifyError(err) — Type-Safe Nested Error Tree

z.treeifyError(err, mapper?) replaces Zod v3's .format() without polluting object keys with a reserved _errors property. Instead, every node in the tree has a clean { errors: U[], properties?: { ... }, items?: [ ... ] } structure:

  • Object keys (string path segments) are placed under .properties.<key>.
  • Array / tuple indices (number path segments) are placed under .items[index].
  • An optional second argument mapper: (issue: $ZodIssue) => U lets you project full issue metadata (such as { code: issue.code, message: issue.message }) instead of plain strings.
if (!result.success) {
  const tree = z.treeifyError(result.error);

  // Root-level form errors (empty path)
  console.log(tree.errors); // []

  // Object property errors
  console.log(tree.properties?.teamName?.errors);
  // ["Too small: expected string to have >=3 characters"]

  // Nested array item property errors
  console.log(tree.properties?.members?.items?.[0]?.properties?.email?.errors);
  // ["Invalid email address"]
}

z.flattenError(err) — Shallow One-Level Form Mapping

When validating flat HTML forms or shallow API payloads, z.flattenError(err, mapper?) groups issues by their top-level key (path[0]):

if (!result.success) {
  const flat = z.flattenError(result.error);
  // flat.formErrors: string[] (issues with path: [])
  // flat.fieldErrors: { teamName?: string[]; members?: string[] }
  console.log(flat.fieldErrors.teamName);
  // ["Too small: expected string to have >=3 characters"]
}

4. Built-In i18n with 49+ Locales (zod/locales)

Zod v4 ships with official translations for over 49 languages and regional variants out of the box—eliminating the need for third-party zod-i18n-map packages. Each locale is individually tree-shakable so your bundle only includes the languages you import.

Supported Locale Codes

Region Built-in Locale Modules (zod/locales/<code.js>)
East & Southeast Asia ja (Japanese), zhCN (zh-CN), zhTW (zh-TW), ko (Korean), vi (Vietnamese), th (Thai), id (Indonesian), ms (Malay), km / kh (Khmer)
South & Central Asia hi (Hindi), bn (Bengali), ta (Tamil), gu (Gujarati), kn (Kannada), ne (Nepali), ur (Urdu), ps (Pashto), tg (Tajik), tk (Turkmen), uz (Uzbek)
Western & Northern Europe en (English), de (German), fr (French), frCA (fr-CA), es (Spanish), it (Italian), pt (Portuguese), ptBR (pt-BR), nl (Dutch), sv (Swedish), da (Danish), fi (Finnish), no / nn (Norwegian), is (Icelandic), ca (Catalan), eo (Esperanto)
Central & Eastern Europe pl (Polish), cs (Czech), sk (Slovak), hu (Hungarian), ro (Romanian), bg (Bulgarian), hr (Croatian), sl (Slovenian), lt (Lithuanian), el (Greek), ru (Russian), uk / ua (Ukrainian), be (Belarusian), mk (Macedonian)
Middle East, Caucasus & Africa ar (Arabic), he (Hebrew), fa (Persian), tr (Turkish), ota (Ottoman Turkish), az (Azerbaijani), hy (Armenian), ka (Georgian), ckb (Central Kurdish), yo (Yoruba)

Configuring Global or Request-Scoped Locales

You can activate a locale globally with z.config(locale()), or pass a locale's localeError function per-request via the { error } parse option in multi-tenant servers:

import * as z from "zod";
import { de, en, ja } from "zod/locales";

// Option A: Set global default locale at application startup
z.config(ja());

const UserSchema = z.object({
  name: z.string().min(2),
  email: z.email(),
});

const resJa = UserSchema.safeParse({ name: "A", email: "invalid" });
if (!resJa.success) {
  console.log(z.prettifyError(resJa.error));
  /*
  ✖ 小さすぎる値: stringは2文字以上である必要があります
    → at name
  ✖ 無効なメールアドレス
    → at email
  */
}

// Option B: Per-request locale override in an HTTP handler (Level 1 precedence)
const locales = { en: en(), ja: ja(), de: de() } as const;

function validateWithLocale(payload: unknown, lang: "en" | "ja" | "de") {
  return UserSchema.safeParse(payload, {
    error: locales[lang].localeError,
  });
}

Next Steps