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, …), apath, 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 toparse, and finally the global error map (z.setErrorMap, orz.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.
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
- Write messages as instructions. “Enter your name”, “Choose a country” — the guidance in writing error messages that tell the reader what to do.
- Key the catalogue by meaning, not by Zod code.
required,tooShort,emailare stable concepts a translator understands; the error map translates Zod’s codes into them. - 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. - 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.
- 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
errorMapargument is request-scoped. - 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.
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.
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).