Forms are often translated except for their errors: the labels are in German, but “This field is required” appears in English because it came from a validation library’s default, the server’s 422 response, or a message string concatenated in code — and the plural in “You have 1 problems” is wrong in English, let alone in Polish.
Error messages are content like any other, but they are generated from rules and data at runtime, which makes them harder to translate than static labels. This page, part of error summary and messaging, sets up a message pipeline — stable keys, parameters, ICU message format, locale-aware formatting — so every error reaches the user in their language, grammatically correct, whether it was produced by the client or the server.
Context and prerequisites
What makes error messages hard to localise:
- They are assembled from parts. “Password must be at least 12 characters” combines a field label, a rule and a number. Concatenating translated parts breaks in languages with different word order.
- They contain plurals. “1 problem” / “3 problems” in English; Polish, Russian and Arabic have more plural categories; Japanese has none.
- They include formatted values. Dates, numbers and currency in messages (“must be after 01/10/2026”, “at least 1,000”) must follow the locale.
- They come from several sources. Schema libraries, native validation (
validationMessage, in the browser’s language), and the server. - Grammar depends on the field. In French, “Saisissez votre adresse e-mail” versus “Saisissez votre nom” — the article and possessive agree with the noun.
The solution is to treat each error as a key plus parameters, translated in one place using ICU MessageFormat (via Intl.PluralRules directly, or a library such as FormatJS or i18next with ICU support).
The core pattern: keys, parameters, ICU messages
// Every error in the app is a key + params. No user-facing strings in validators.
export interface ErrorDescriptor { key: string; params?: Record<string, unknown> }
// Catalogue excerpt (ICU MessageFormat). One file per locale, edited by translators.
export const messages = {
en: {
"field.required.email": "Enter your email address.",
"field.required.name": "Enter your full name.",
"field.minLength": "{label} must be at least {min, number} characters.",
"field.dateAfter": "Enter a date after {min, date, long}.",
"summary.heading": "{count, plural, one {There is a problem} other {There are # problems}}",
},
pl: {
"field.required.email": "Wpisz swój adres e-mail.",
"field.required.name": "Wpisz swoje imię i nazwisko.",
"field.minLength": "{label} musi mieć co najmniej {min, number} {min, plural, one {znak} few {znaki} many {znaków} other {znaku}}.",
"field.dateAfter": "Podaj datę późniejszą niż {min, date, long}.",
"summary.heading": "{count, plural, one {Wystąpił # problem} few {Wystąpiły # problemy} many {Wystąpiło # problemów} other {Wystąpiło # problemu}}",
},
} as const;
import { IntlMessageFormat } from "intl-messageformat";
type Locale = keyof typeof messages;
export function translate(locale: Locale, d: ErrorDescriptor, labels: Record<string, string>): string {
const catalogue = messages[locale] as Record<string, string>;
// Prefer a field-specific key ("field.required.email") when it exists.
const specific = d.params?.field ? `${d.key}.${d.params.field}` : undefined;
const source = (specific && catalogue[specific]) ?? catalogue[d.key] ?? catalogue["field.invalid"] ?? d.key;
const label = d.params?.field ? labels[String(d.params.field)] : undefined;
return new IntlMessageFormat(source, locale).format({ ...d.params, label }) as string;
}
// Validators return descriptors, not sentences:
export const minLength = (field: string, min: number) => (v: string): ErrorDescriptor | null =>
v.trim().length < min ? { key: "field.minLength", params: { field, min } } : null;
Step-by-step walkthrough
- Make validators return keys and parameters.
{ key: "field.minLength", params: { field: "password", min: 12 } }. Rendering decides the language; validation does not. - Store messages in ICU MessageFormat. Plurals (
{count, plural, …}), selects ({gender, select, …}) and formatted arguments ({min, number},{date, date, long}) are handled per locale by the formatter. - Prefer full sentences per field for common errors. “Enter your email address” as its own key reads naturally in every language; use templates with
{label}for the long tail. - Format values with the locale. Numbers and dates inside messages go through ICU arguments, so “1,000” becomes “1 000” in French and dates follow local order — the same concern as locale-aware number and currency inputs.
- Route schema and native messages through the same keys. Map schema issue codes to keys with an error map, as in custom Zod error maps and localised messages, and replace native
validationMessage(which is in the browser’s language) with your own. - Make the server return keys too. Return
codeandparamsin error responses and translate on the client, or have the server translate with the request’s locale. Never mix languages on one screen.
Why keys beat English strings as identifiers
Using the English sentence as the translation key (“Enter your email address.”) seems convenient until the English wording changes: every translation keyed on the old sentence becomes orphaned, or worse, silently falls back to English. Stable, meaningful keys (field.required.email) decouple the identifier from the wording, so copy editors can improve English messages without breaking other languages, and translators can see what a message is for from its key. Keys also make the connection between validators and messages explicit and searchable — you can find every place that can produce field.minLength.
Failure modes and edge cases
1. Browser-native messages in the wrong language
input.validationMessage is in the browser’s UI language, not your page’s. A French page in an English browser shows English bubbles. Use novalidate and your own messages, per using the Constraint Validation API with custom form state.
2. Right-to-left languages
In Arabic and Hebrew, error icons and alignment must mirror, and mixed-direction content (an email address inside an RTL sentence) needs isolation. Wrap user-supplied values in <bdi> or use Unicode isolates, and set dir on the form.
3. Hidden “Error:” prefixes
Visually hidden prefixes for screen readers ("Error: ") must be translated too. Put them in the catalogue with the other messages.
4. Message length
German and Finnish messages can be much longer than English ones. Error containers must wrap text and not truncate; test with the longest locale and at 200% zoom.
5. Server messages in the wrong locale
A server that returns English detail strings in Problem Details responses breaks localisation. Either send Accept-Language and have the server translate, or rely on code and translate on the client, as in problem details (RFC 9457) for form errors.
Verification checklist
Frequently Asked Questions
Is Intl.PluralRules enough without a library?
For simple count messages, yes: new Intl.PluralRules(locale).select(n) returns the category, and you pick the matching string. ICU MessageFormat libraries add nesting, selects and inline number/date formatting, which error messages need often enough to be worth it.
Should messages include the field label?
In the summary, yes — messages appear away from their fields. Inline, the label is right above, so “Enter your email address” works in both places. Using the same full sentence in both keeps them consistent.
How do I handle gendered languages?
Where a message’s grammar depends on the field’s noun, give each field its own message key for common errors. For templated messages, pass a grammatical gender parameter and use ICU select — translators decide the forms.