Localizing Custom Validation Messages for Multiple Locales
When a single form serves users across several languages, the native browser messages honour the user’s locale automatically but your custom validity messages do not — this page shows how to drive setCustomValidity() from a per-locale message catalogue so every constraint error reads in the visitor’s language while still flowing through the native validation UI.
When to Use This Message-Catalogue Approach
Reach for a locale-keyed message catalogue when your form must speak more than one language and you have already decided to override the browser’s default constraint strings. If you only ever ship one language, or you are happy with the browser’s built-in wording, you do not need any of this — that is the territory covered by How to Use setCustomValidity Correctly.
- Choose this when the wording must change per locale but the constraints (required, pattern, min/max) stay identical across languages.
- Choose a validation library such as Zod schema validation instead when your messages come bundled with a schema and you are already translating those.
- Keep the native Constraint Validation API as the delivery mechanism — you localize the strings, not the plumbing.
Driving setCustomValidity() From a Per-Locale Catalogue
The baseline is unchanged: the form ships novalidate, and one manual reportValidity() call drives the UI. The only addition is a lookup that turns a fired ValidityState flag into a translated string before we hand it to setCustomValidity().
// messages.ts — one catalogue keyed by locale, then by ValidityState flag.
type ValidityKey = "valueMissing" | "patternMismatch" | "typeMismatch" | "tooShort";
const CATALOGUE: Record<string, Record<ValidityKey, string>> = {
en: {
valueMissing: "This field is required.",
patternMismatch: "The format is not valid.",
typeMismatch: "Enter a valid email address.",
tooShort: "Enter at least 8 characters.",
},
fr: {
valueMissing: "Ce champ est obligatoire.",
patternMismatch: "Le format n’est pas valide.",
typeMismatch: "Saisissez une adresse e-mail valide.",
tooShort: "Saisissez au moins 8 caractères.",
},
de: {
valueMissing: "Dieses Feld ist erforderlich.",
patternMismatch: "Das Format ist ungültig.",
typeMismatch: "Geben Sie eine gültige E-Mail-Adresse ein.",
tooShort: "Geben Sie mindestens 8 Zeichen ein.",
},
};
// Resolve the active locale once: prefer an explicit <html lang>, fall back to
// the browser, then to English. Normalise "fr-CA" down to "fr".
function resolveLocale(): string {
const raw = document.documentElement.lang || navigator.language || "en";
const base = raw.toLowerCase().split("-")[0];
return base in CATALOGUE ? base : "en";
}
// Map the fired ValidityState flag to a catalogue key. Order matters: the first
// truthy flag wins, mirroring how the browser reports a single message.
function firstFailingKey(v: ValidityState): ValidityKey | null {
if (v.valueMissing) return "valueMissing";
if (v.typeMismatch) return "typeMismatch";
if (v.patternMismatch) return "patternMismatch";
if (v.tooShort) return "tooShort";
return null;
}
function localizedMessage(input: HTMLInputElement, locale: string): string {
const key = firstFailingKey(input.validity);
return key ? CATALOGUE[locale][key] : "";
}
// form.ts — wire the catalogue into the novalidate + reportValidity baseline.
const form = document.querySelector<HTMLFormElement>("#signup")!;
const locale = resolveLocale();
form.addEventListener("submit", (event) => {
// Clear any stale custom message first so validity is recomputed natively.
for (const el of form.elements) {
if (el instanceof HTMLInputElement) el.setCustomValidity("");
}
// Apply a translated message to each element that is still invalid.
for (const el of form.elements) {
if (el instanceof HTMLInputElement && !el.validity.valid) {
el.setCustomValidity(localizedMessage(el, locale));
}
}
// Single manual call drives the native bubble in the resolved language.
if (!form.reportValidity()) event.preventDefault();
});
The setCustomValidity("") reset on every submit is load-bearing: a non-empty custom message keeps an element permanently invalid, so you must clear it before the browser re-evaluates the built-in constraints.
Catalogue Options Reference
| Option | Type | Default | Purpose |
|---|---|---|---|
locale |
string |
"en" |
The resolved two-letter key selecting a catalogue branch. |
CATALOGUE[locale] |
Record<ValidityKey, string> |
required | Per-locale map of validity flags to translated strings. |
resolveLocale() source |
string |
document.documentElement.lang |
Where the active locale is read from before falling back to navigator.language. |
firstFailingKey() order |
ValidityKey[] |
valueMissing → typeMismatch → patternMismatch → tooShort |
Priority used when several flags fire at once. |
| fallback key | "en" |
"en" |
Locale used when the requested branch is missing. |
| reset call | setCustomValidity("") |
"" |
Clears the stale message so native constraints re-evaluate. |
Verification Steps
import { test, expect } from "@playwright/test";
test("required message localizes to French", async ({ page }) => {
await page.goto("/signup?lang=fr");
await page.locator("#signup button[type=submit]").click();
const email = page.locator("#email");
const message = await email.evaluate(
(el: HTMLInputElement) => el.validationMessage,
);
expect(message).toBe("Ce champ est obligatoire.");
});
Edge Cases & Failure Modes
Stale custom message pins the field invalid. If you set a translated string but never call setCustomValidity("") again, the element stays invalid forever even after the user corrects it. Always clear every element at the top of the submit handler before re-evaluating, as shown above.
Region subtags miss the catalogue. A browser reporting fr-CA or en-GB will not match a catalogue keyed by fr or en unless you normalise. Split on - and lower-case the base tag in resolveLocale(), and keep a guaranteed en fallback so an unknown locale degrades gracefully.
Multiple flags fire but only one message shows. The native UI surfaces a single message per field, so if both valueMissing and tooShort are true your firstFailingKey() order decides which string wins. Keep that order deliberate; for richer per-flag output, drive your own DOM instead — see accessible error messaging and pair it with focus management after a failed submit.
Interpolating Bound Values With a Message-Function Catalogue
The flat string catalogue breaks down the moment a message needs a number in it. tooShort should tell the user how many characters are required, and rangeOverflow should name the actual max. Hard-coding “8 characters” into the string duplicates the constraint and drifts the instant you change the minlength attribute. The fix is to store either a string or a function under each key, and to read the bound value straight off the ValidityState element so the constraint remains the single source of truth.
// Each catalogue entry is either a static string or a function of the element.
type MessageFn = (input: HTMLInputElement) => string;
type Entry = string | MessageFn;
type Catalogue = Partial<Record<keyof ValidityState, Entry>>;
const CATALOGUE_FN: Record<string, Catalogue> = {
en: {
valueMissing: "This field is required.",
// minLength is reflected from the attribute, so the message never drifts.
tooShort: (el) => `Enter at least ${el.minLength} characters.`,
rangeOverflow: (el) => `Choose a value no higher than ${el.max}.`,
},
fr: {
valueMissing: "Ce champ est obligatoire.",
tooShort: (el) => `Saisissez au moins ${el.minLength} caractères.`,
rangeOverflow: (el) => `Choisissez une valeur inférieure ou égale à ${el.max}.`,
},
};
// Resolve an entry to a concrete string, calling it if it is a function.
function resolveEntry(entry: Entry | undefined, input: HTMLInputElement): string {
if (typeof entry === "function") return entry(input);
return entry ?? "";
}
function localizedMessageFn(input: HTMLInputElement, locale: string): string {
const branch = CATALOGUE_FN[locale] ?? CATALOGUE_FN.en;
// Iterate the ValidityState flags in a deliberate priority order.
const order: (keyof ValidityState)[] = [
"valueMissing", "typeMismatch", "tooShort", "rangeOverflow",
];
for (const key of order) {
if (input.validity[key]) return resolveEntry(branch[key], input);
}
return "";
}
Because the function receives the live element, it can also localize numbers rather than just splice them in. Passing el.minLength through new Intl.NumberFormat(locale).format(...) renders 1000 as 1 000 in French and 1,000 in English — a detail translators expect and hand-written concatenation always misses.
Loading Catalogues Lazily for Larger Locale Sets
Inlining every locale into the initial bundle is fine for three languages and wasteful for thirty. Each catalogue is dead weight for every visitor except the fraction who read that language, yet it ships to all of them and blocks parse. Once you cross a handful of locales, split each branch into its own module and resolve it with a dynamic import() keyed off the locale you already computed.
// locales/fr.ts default-exports one Catalogue; likewise de.ts, ja.ts, etc.
const loaders: Record<string, () => Promise<{ default: Catalogue }>> = {
fr: () => import("./locales/fr.js"),
de: () => import("./locales/de.js"),
};
async function loadCatalogue(locale: string): Promise<Catalogue> {
const loader = loaders[locale];
if (!loader) return (await import("./locales/en.js")).default;
return (await loader()).default;
}
Resolve the catalogue once during page setup — not inside the submit handler — so the network round-trip never sits on the critical path between the user pressing Submit and the native bubble appearing. reportValidity() is synchronous; if the catalogue is still loading when the form is submitted, fall back to the browser’s own localized default by leaving setCustomValidity("") in place rather than blocking on the promise. The English module stays statically imported as the guaranteed floor, so a failed fetch degrades to readable text instead of an empty bubble.
Frequently Asked Questions
Do custom validity messages override the browser's localized defaults?
Yes. As soon as you call setCustomValidity() with a non-empty string, that string replaces the browser's built-in message for that element. If you want the browser's own localized default back, call setCustomValidity("") and let the native constraint report itself.
How do I localize messages that come from asynchronous server checks?
Return a stable error code from the server rather than a prose string, then look that code up in the same locale catalogue on the client. That keeps translation in one place. See asynchronous server checks for wiring the request itself.
Should I interpolate values like min length into localized strings?
Prefer template functions over concatenation so word order stays translator-controlled. Store an entry like (n) => `Enter at least ${n} characters.` and read the bound value from input.minLength. This avoids grammatically broken strings in locales where the number does not sit where English puts it.
Does the native validation bubble respect the document's text direction for RTL locales?
The browser renders the bubble following the resolved direction of the field, so setting dir="rtl" on the <html> element (or the input) is enough for Arabic or Hebrew strings to align correctly — you do not style the bubble yourself. Set dir alongside lang when you resolve the locale so the translated string and its layout stay consistent. If you have replaced the native bubble with your own DOM, you own the direction instead and must mirror it manually.
How do I keep the catalogue and the ValidityKey union from drifting apart?
Derive the key type from a single source and let TypeScript enforce completeness. Typing each branch as Record<ValidityKey, string> makes the compiler flag any locale that forgets a key, and a satisfies clause on the catalogue object catches typos in the flag names against the real ValidityState interface. This turns a whole class of missing-translation bugs into build errors instead of empty bubbles at runtime.
Related Guides
- How to Use setCustomValidity Correctly — the single-language foundation this page extends.
- Reading ValidityState Flags — how to know which catalogue key to look up.
- checkValidity vs reportValidity — choose the right call to surface your translated messages.
- Constraint Validation API Deep Dive — the native machinery delivering every localized string.
- HTML5 Pattern Attribute Regex Examples — constraints whose failures your catalogue translates.