Zod’s default messages are written for developers — “String must contain at least 1 character(s)”, “Expected number, received nan”, “Invalid enum value. Expected ‘uk’ | ‘us’, received ‘’” — and shipping them to users is one of the most common ways a well-engineered form ends up with confusing, untranslatable error text.

There are three layers at which to fix this: messages written inline in the schema, a global error map that rewrites issues by code, and a translation step that turns stable issue codes into localised sentences. This page combines them so every field gets a message that says what to do, in the user’s language, without scattering strings through schemas. It extends integrating Zod for schema validation.


Context and prerequisites

How Zod produces a message:

  • Each failed check creates an issue with a code (too_small, invalid_type, invalid_string, invalid_enum_value, custom, …), a path, and code-specific details (minimum, validation: "email", received, options).
  • The message is chosen, in priority order, from: a message passed to that specific check (.min(1, "…")), a schema-level error map, a contextual error map passed to parse, and finally the global error map (z.setErrorMap, or z.config({ customError }) in Zod 4), falling back to Zod’s defaults.

So a single global map can translate every issue the application produces, while individual schemas can still override where a field needs a specific sentence.

Where Zod looks for a message When a check fails, Zod first uses a message passed directly to that check, such as min one with enter your name. If there is none, it uses an error map attached to the schema. Then a contextual error map passed in the parse call. Then the global error map set once for the application. Only if none of these provides a message does it use its built-in English default, which is written for developers. Message on the check .min(1, "Enter your name.") Most specific; use for sentences unique to one field. Schema error map z.string({ errorMap }) Per-field overrides by code. Contextual map in parse() schema.parse(v, { errorMap }) Per-request locale, if needed. Global error map Set once at startup. Translates every remaining issue by code. Built-in default "String must contain at least 1 character(s)" Developer-facing; never let it reach users.

The core pattern: a global map keyed by code, plus field labels

import { z } from "zod";

type Locale = "en" | "de";
type Catalogue = Record<string, (p: Record<string, unknown>) => string>;

// Messages keyed by stable codes. Each says what to DO, not which rule failed.
const catalogues: Record<Locale, Catalogue> = {
  en: {
    required: ({ label }) => `Enter ${label}.`,
    tooShort: ({ label, min }) => `${cap(label)} must be at least ${min} characters.`,
    tooLong: ({ label, max }) => `${cap(label)} must be ${max} characters or fewer.`,
    email: () => "Enter an email address like [email protected].",
    number: ({ label }) => `Enter ${label} as a number.`,
    minNumber: ({ label, min }) => `${cap(label)} must be ${min} or more.`,
    choose: ({ label }) => `Choose ${label}.`,
    invalid: ({ label }) => `Check ${label}.`,
  },
  de: {
    required: ({ label }) => `Geben Sie ${label} ein.`,
    tooShort: ({ label, min }) => `${cap(label)} muss mindestens ${min} Zeichen lang sein.`,
    tooLong: ({ label, max }) => `${cap(label)} darf höchstens ${max} Zeichen lang sein.`,
    email: () => "Geben Sie eine E-Mail-Adresse wie [email protected] ein.",
    number: ({ label }) => `Geben Sie ${label} als Zahl ein.`,
    minNumber: ({ label, min }) => `${cap(label)} muss mindestens ${min} sein.`,
    choose: ({ label }) => `Wählen Sie ${label}.`,
    invalid: ({ label }) => `Prüfen Sie ${label}.`,
  },
};
const cap = (s: unknown) => String(s).charAt(0).toUpperCase() + String(s).slice(1);

// Field labels per locale, keyed by path ("address.postcode").
export type Labels = Record<string, string>;

export function makeErrorMap(locale: Locale, labels: Labels): z.ZodErrorMap {
  const t = catalogues[locale];
  return (issue, ctx) => {
    const label = labels[issue.path.join(".")] ?? labels[String(issue.path.at(-1))] ?? "this field";
    switch (issue.code) {
      case z.ZodIssueCode.invalid_type:
        if (issue.received === "undefined" || issue.received === "null") return { message: t.required({ label }) };
        if (issue.expected === "number") return { message: t.number({ label }) };
        break;
      case z.ZodIssueCode.too_small:
        if (issue.type === "string") return { message: Number(issue.minimum) <= 1 ? t.required({ label }) : t.tooShort({ label, min: issue.minimum }) };
        if (issue.type === "number") return { message: t.minNumber({ label, min: issue.minimum }) };
        break;
      case z.ZodIssueCode.too_big:
        if (issue.type === "string") return { message: t.tooLong({ label, max: issue.maximum }) };
        break;
      case z.ZodIssueCode.invalid_string:
        if (issue.validation === "email") return { message: t.email({ label }) };
        break;
      case z.ZodIssueCode.invalid_enum_value:
        return { message: t.choose({ label }) };
    }
    // Unknown case: a neutral, still user-facing fallback — never the raw default.
    return { message: ctx.defaultError && t.invalid({ label }) };
  };
}

// Apply per request/render with the user's locale and the form's labels.
export function parseWithMessages<T extends z.ZodTypeAny>(schema: T, data: unknown, locale: Locale, labels: Labels) {
  return schema.safeParse(data, { errorMap: makeErrorMap(locale, labels) });
}
const labels = { name: "your name", email: "your email address", age: "your age", country: "a country" };
const result = parseWithMessages(Signup, input, "en", labels);
// name "" → "Enter your name."  ·  age "x" → "Enter your age as a number."

Step-by-step walkthrough

  1. Write messages as instructions. “Enter your name”, “Choose a country” — the guidance in writing error messages that tell the reader what to do.
  2. Key the catalogue by meaning, not by Zod code. required, tooShort, email are stable concepts a translator understands; the error map translates Zod’s codes into them.
  3. Treat “min length 1” as required. z.string().min(1) is how most schemas express required text; mapping it to “Enter …” rather than “at least 1 character” reads naturally.
  4. Inject field labels by path. Messages that name the field (“Enter your email address”) are clearer in summaries, where the message appears away from the input.
  5. Pass the map per parse, not globally, if locales vary per request. On a server handling many locales, a global map is shared state; the contextual errorMap argument is request-scoped.
  6. Keep specific overrides in the schema. A field whose rule needs a unique sentence (“Your password needs a symbol”) uses an inline message, which takes priority over the map.

Why messages should not be built in the schema module

Inline strings in a shared schema lock it to one language and one tone, and they travel everywhere the schema does — including server code, where messages might be logged, and other apps with different vocabulary. Keeping the schema free of user-facing text (or limited to rare, field-specific overrides with translation keys) and applying the error map at the edge where the user’s locale is known keeps the schema reusable and the messages consistent. It also gives translators one catalogue to work through instead of strings scattered across validation code.

Default messages and their replacements String must contain at least 1 character becomes Enter your name. String must contain at least 8 characters becomes Password must be at least 8 characters. Invalid email becomes Enter an email address like name at example dot com. Expected number, received nan becomes Enter your age as a number. Invalid enum value with expected options becomes Choose a country. Required becomes Enter your email address. Zod default User-facing message String must contain at least 1 character(s) Enter your name. String must contain at least 8 character(s) Password must be at least 8 characters. Invalid email Enter an email address like [email protected]. Expected number, received nan Enter your age as a number. Invalid enum value. Expected 'uk' | 'us'… Choose a country. Required Enter your email address.

Failure modes and edge cases

1. Zod 3 vs Zod 4 APIs

Zod 4 replaced errorMap with a unified error parameter and z.config({ customError }), and ships built-in locales via z.config(z.locales.de()). The approach is identical — map issue codes to your messages — but the function signature differs. Check which version your adapters (form library resolvers) expect.

2. Messages that expose internals

Fallbacks like ctx.defaultError can leak “Expected string, received object” for malformed API input. Always return a neutral user-facing sentence in the fallback and log the original for developers.

3. Grammar in other languages

Interpolating a label into a sentence works poorly in languages with grammatical gender or case (“Geben Sie Ihre E-Mail-Adresse ein” vs “Ihren Namen”). For such locales, store full sentences per field rather than a template plus label, or use ICU message format with select clauses.

4. Server and client messages disagree

If the client uses the error map and the server returns raw Zod messages in a 422, users see two voices. Run the same map on the server with the request’s locale, or return issue codes and translate on the client — see localising form error messages.

5. Refinement messages

superRefine issues use code custom, which the map cannot interpret generically. Give custom issues a params: { key: "passwordsMatch" } and translate by key in the map.

Three layers, three jobs Inline messages in the schema handle the rare field that needs a unique sentence. The error map translates Zod issue codes and details into stable message keys plus parameters such as label and minimum. The message catalogue turns keys and parameters into localised sentences, and is what translators edit. Inline message Rare, field-specific sentences. Error map Zod code → message key + params. Catalogue Key + params → localised sentence. What translators edit.

Verification checklist


Frequently Asked Questions

Should I put translation keys in the schema instead of messages?

That works well: .min(1, { message: "errors.name.required" }), then translate keys at render time. It keeps schemas language-neutral while allowing per-field specificity. The error map approach covers the fields that do not need a specific key.

Are there ready-made translations for Zod?

Community packages such as zod-i18n-map provide error maps backed by i18next with many locales, and Zod 4 includes built-in locales. They translate Zod’s generic messages; you will still want your own wording for key fields.

How do I test messages?

Snapshot the messages for a table of invalid inputs per locale, and assert that none contains Zod’s default phrasing (a regex for “must contain at least” or “Expected” catches regressions).


Related

← Integrating Zod for Schema Validation