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).

Why string concatenation fails Concatenating a label with a phrase such as label plus is required works in English but produces wrong word order and grammar in German and Japanese. A template with a plain number placeholder breaks plurals, giving you have 1 problems. An ICU message with plural and select clauses per locale produces correct grammar and plurals everywhere. A full sentence per field and rule, stored in the catalogue, is always correct but more work to maintain. Approach English Other languages label + " is required" Email is required word order and grammar break "{n} problems" 1 problems plural categories wrong ICU plural/select 1 problem / 3 problems correct per locale sentence per field+rule exact wording exact; more to maintain

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

  1. Make validators return keys and parameters. { key: "field.minLength", params: { field: "password", min: 12 } }. Rendering decides the language; validation does not.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. Make the server return keys too. Return code and params in 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.

From a failed rule to a localised sentence The minimum length validator returns the key field.minLength with parameters field password and min twelve. The translator looks for a field-specific key first and falls back to the generic key. It formats the ICU message for the user's locale, inserting the translated label and formatting the number, and applying plural rules. The resulting sentence is rendered both inline next to the field and in the error summary. Validator result { key: field.minLength, params: { field, min: 12 } } No language in validation code. Choose the message field-specific key, else generic. Common errors get natural full sentences. ICU format for the locale label, number, plural rules. "Hasło musi mieć co najmniej 12 znaków." Render in both places Inline and in the summary. Same text, one source.

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.

Sources of error text and how to localise each Your own validators return keys and parameters that are translated in the catalogue. Schema libraries are routed through an error map that converts issue codes to the same keys. Native browser validation messages are replaced by your own via novalidate and custom rendering, because they follow the browser language. The server returns codes and parameters, or messages translated using the request's Accept-Language. Your validators Return keys + params. Schema library Error map → same keys. Native validation Browser language. Replace with yours. Server Codes + params, or Accept-Language.

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.


Related

← Error Summary and Messaging