Keeping Client and Server Error Messages in Sync
Why does the browser say “Password must be at least 12 characters” while the server, for the same mistake, says “password: String must contain at least 12 character(s)”? Because the two messages come from different places — one hand-written in a component, the other generated by a schema library’s defaults — and nothing forces them to agree. This recipe moves every validation message into a shared catalogue keyed by stable IDs, passes parameters rather than baking numbers into strings, localises both sides from the same catalogue, sends message keys (not only text) over the wire, and adds a test that fails the build when a schema produces a message the catalogue does not know. The browser still delivers each message through setCustomValidity() and the Constraint Validation API, so nothing about focus or announcement changes.
When to Centralise Validation Messages
Centralising pays off as soon as the same rule is enforced in two places, which in any properly defended form is always. It is particularly valuable when:
- The product is localised, so each message exists in several languages that must also stay in sync.
- Rules carry parameters — minimum lengths, limits, dates — that change over time.
- Content designers own the wording, and need one file to edit rather than a hunt through components and API code.
The shared message module belongs next to the shared schemas described in shared client–server schemas; in a monorepo it is simply another entry point of the same package, set up as in sharing Zod schemas in a monorepo.
Minimal Working Message Catalogue
// packages/validation/src/messages.ts
export type MessageKey =
| "required" | "email.invalid" | "string.tooShort" | "string.tooLong"
| "number.tooSmall" | "date.notPast" | "terms.required";
type Params = Record<string, string | number>;
export const catalogues: Record<string, Record<MessageKey, string>> = {
en: {
required: "Enter your {field}.",
"email.invalid": "Enter an email address like name@example.com.",
"string.tooShort": "{Field} must be at least {min} characters.",
"string.tooLong": "{Field} must be {max} characters or fewer.",
"number.tooSmall": "{Field} must be {min} or more.",
"date.notPast": "{Field} must be in the past.",
"terms.required": "You must accept the terms to continue.",
},
de: {
required: "Geben Sie Ihr {field} ein.",
"email.invalid": "Geben Sie eine E-Mail-Adresse wie name@example.com ein.",
"string.tooShort": "{Field} muss mindestens {min} Zeichen lang sein.",
"string.tooLong": "{Field} darf höchstens {max} Zeichen lang sein.",
"number.tooSmall": "{Field} muss mindestens {min} sein.",
"date.notPast": "{Field} muss in der Vergangenheit liegen.",
"terms.required": "Sie müssen die Bedingungen akzeptieren, um fortzufahren.",
},
};
export interface MessageRef { key: MessageKey; params?: Params }
export function render(ref: MessageRef, locale = "en", fieldLabels: Record<string, string> = {}): string {
const template = (catalogues[locale] ?? catalogues.en)[ref.key];
const params = { ...ref.params };
const field = String(params.field ?? "");
const label = fieldLabels[field] ?? field;
return template
.replace("{field}", label.toLowerCase())
.replace("{Field}", label.charAt(0).toUpperCase() + label.slice(1))
.replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? `{${k}}`));
}
// Schemas carry message REFS, serialised into Zod's message string as JSON.
export const msg = (key: MessageKey, params?: Params) => JSON.stringify({ key, params } satisfies MessageRef);
export const parseRef = (message: string): MessageRef => {
try { return JSON.parse(message) as MessageRef; } catch { return { key: "required" }; }
};
// packages/validation/src/signup.ts
import { z } from "zod";
import { msg } from "./messages";
export const signupSchema = z.object({
email: z.string().trim().min(1, msg("required", { field: "email" })).email(msg("email.invalid")),
password: z.string()
.min(12, msg("string.tooShort", { field: "password", min: 12 }))
.max(128, msg("string.tooLong", { field: "password", max: 128 })),
});
// Server: send keys + params; clients render in their own locale
const parsed = signupSchema.safeParse(input);
if (!parsed.success) {
const errors = Object.fromEntries(parsed.error.issues.map((i) => [i.path.join("."), parseRef(i.message)]));
return Response.json({ errors }, { status: 422 });
}
// Browser: the same render() for client-side and server-side failures
function applyRefs(form: HTMLFormElement, errors: Record<string, MessageRef>, locale: string): void {
const labels = Object.fromEntries(
[...form.querySelectorAll<HTMLLabelElement>("label[for]")].map((l) => [
(document.getElementById(l.htmlFor) as HTMLInputElement | null)?.name ?? "", l.textContent!.trim(),
]),
);
for (const el of form.querySelectorAll<HTMLInputElement>("input[name]")) {
const ref = errors[el.name];
el.setCustomValidity(ref ? render(ref, locale, labels) : "");
}
}
Sending the key and parameters instead of rendered text is the core idea. The server does not need to know the user’s language or the exact label on the form; the client renders the message with its own locale and its own visible field labels, so the error says “Password must be at least 12 characters” using the same word the label uses, even if the label is later renamed to “Passphrase”.
Message Catalogue Option Reference
| Option | Type | Default | Purpose |
|---|---|---|---|
MessageKey |
string union | — | Compile-time check that every key exists |
catalogues[locale] |
Record<MessageKey, string> |
en fallback |
One file per language, same keys |
| Parameters | Record<string, string | number> |
none | Numbers and names never baked into text |
{field} / {Field} |
placeholders | label lookup | Uses the visible label, lower- or sentence-cased |
| Wire format | MessageRef |
key + params | Locale-neutral server responses |
| Fallback | behaviour | en then raw key |
Never shows an empty message |
{Field} with a capital letter is a small detail with a large effect on quality: messages that begin with the field name (“Password must be…”) need sentence case, while those that embed it (“Enter your password”) need lower case. Handling both in render keeps translators from having to write two variants of every label.
Verification Steps
import { describe, it, expect } from "vitest";
import { catalogues, parseRef } from "../src/messages";
import * as schemas from "../src";
describe("message catalogue", () => {
it("every locale defines every key", () => {
const keys = Object.keys(catalogues.en).sort();
for (const [locale, cat] of Object.entries(catalogues)) {
expect(Object.keys(cat).sort(), locale).toEqual(keys);
}
});
it("every schema failure maps to a catalogue key", () => {
for (const schema of Object.values(schemas)) {
const result = (schema as any).safeParse?.({});
if (!result || result.success) continue;
for (const issue of result.error.issues) {
expect(catalogues.en).toHaveProperty(parseRef(issue.message).key);
}
}
});
});
Edge Cases and Failure Modes
Library default messages leaking through. Any rule written without an explicit message falls back to Zod’s default English text, which then fails parseRef and shows the generic fallback. The drift test above catches it. A global z.setErrorMap that maps issue codes to catalogue keys closes the gap for rules you forgot to annotate.
Server-only errors outside the schema. “Email already registered” comes from a database check, not the schema. Give it a catalogue key too (email.taken) and return it as a MessageRef, so it renders in the user’s language like everything else.
Pluralisation. “{min} characters” is wrong for min: 1 in English and wrong in more complex ways in other languages. Use Intl.PluralRules in render, or a message format library that supports ICU plural syntax, when parameters are counts.
const plural = new Intl.PluralRules(locale);
const unit = plural.select(Number(params.min)) === "one" ? "character" : "characters";
Messages used as logic. Code that checks if (error === "Password must be at least 12 characters") breaks on every copy edit. Compare keys, never rendered text.
Letting Native Messages and Catalogue Messages Coexist
The browser’s own messages for required, type="email" and minlength are localised by the browser into the browser’s language, which may differ from the page’s. A page in German viewed in an English browser will show English native bubbles next to German custom errors. The consistent fix is to route native failures through the catalogue as well: read the ValidityState flags after checkValidity() and call setCustomValidity() with the rendered catalogue message, as described in localizing custom validation messages. Then every visible message on the page — native, schema or server — comes from one catalogue in one language.
function nativeToRef(el: HTMLInputElement): MessageRef | undefined {
const v = el.validity;
if (v.valueMissing) return { key: "required", params: { field: el.name } };
if (v.typeMismatch && el.type === "email") return { key: "email.invalid" };
if (v.tooShort) return { key: "string.tooShort", params: { field: el.name, min: el.minLength } };
return undefined;
}
Governing Message Changes
A catalogue makes messages easy to change, which means it needs light governance. Keep keys stable and descriptive — string.tooShort, not err_17 — and never reuse a key for a different meaning, because older clients may still render it. Treat a change to a message’s parameters (adding {max}) as a breaking change for translators and old clients, and add new keys instead of repurposing old ones. Give content designers ownership of the catalogue files through code review rules, so wording changes do not require engineers, and run the drift test in CI so no one can ship a rule without a message. The writing guidance itself lives in writing clear inline error message copy.
Frequently Asked Questions
How do I make client and server validation messages identical?
Define every message once in a shared catalogue keyed by stable IDs, have schemas emit keys and parameters instead of text, and render them with the same function on both sides.
Should the server send error text or error codes?
Send a stable key plus parameters, and optionally a default rendered text. Clients render the key in the user's locale with the form's visible labels, so messages match the page exactly.
How do I localise schema validation messages?
Keep one catalogue per locale with identical keys, render messages at the client edge with the page locale, and route native browser messages through the same catalogue with setCustomValidity().
How can I catch messages that drift or go missing?
Add tests that every locale defines every key and that every schema failure maps to a catalogue key. Run them in CI with the rest of the validation package tests.
Related Guides
- Shared Client–Server Schemas — the rules these messages belong to.
- Localizing Custom Validation Messages — native messages in the page’s language.
- Problem Details (RFC 9457) for Field Errors — carrying message keys in a standard response format.
- WCAG 3.3.3 Error Suggestion Patterns — what every message in the catalogue should do.