Translating error messages
Changeset errors keep their message template and its values apart until you render them, which is what makes translation and plurals possible.
Setup
The code below uses this schema; no database is needed, since the errors come from a changeset alone.
ts
import { type ChangesetError, cast, Schema, t, traverseErrors } from "egon-orm";
class User extends Schema("users", {
id: t.integer().primaryKey().dbGenerated(),
email: t.string(),
name: t.string(),
age: t.integer().nullable(),
}) {
static changeset(user: User | typeof User, params: unknown) {
return cast(user, params, ["email", "name", "age"])
.validateRequired(["email", "name"])
.validateLength("name", { min: 2 })
.validateNumber("age", { greaterThanOrEqualTo: 13 })
.uniqueConstraint("email");
}
}A catalogue
ts
// The default messages are the keys. Placeholders such as {count} are filled from meta.
type Entry = string | Partial<Record<Intl.LDMLPluralRule, string>>;
const fr: Record<string, Entry> = {
"can't be blank": "doit être rempli",
"has already been taken": "est déjà utilisé",
"is invalid": "n'est pas valide",
"should be at least {count} character(s)": {
one: "doit contenir au moins {count} caractère",
other: "doit contenir au moins {count} caractères",
},
"must be greater than or equal to {number}": "doit être supérieur ou égal à {number}",
};
export function translator(catalogue: Record<string, Entry>, locale: string) {
const plurals = new Intl.PluralRules(locale);
return (error: ChangesetError): string => {
const entry = catalogue[error.message] ?? error.message; // unknown messages stay as they are
const count = error.meta.count;
const template =
typeof entry === "string"
? entry
: (entry[typeof count === "number" ? plurals.select(count) : "other"] ??
entry.other ??
error.message);
return template.replace(/\{(\w+)\}/g, (match, key: string) =>
key in error.meta ? String(error.meta[key]) : match,
);
};
}- The keys are the default messages (
can't be blank,should be at least {count} character(s), …), listed with each validation in Changesets. error.metaholds the values for the placeholders:{ count: 2 },{ number: 13 }.Intl.PluralRulespicks the plural form for the language.
Using it
ts
const changeset = User.changeset(User, { email: "", name: "A", age: 9 });
traverseErrors(changeset, translator(fr, "fr"));traverseErrors returns the same tree as errorMessages(), with your function's result for each error, and descends into nested changesets.
Good to know
- A custom message (
{ message: "…" }on a validation) is a key like any other. error.validation("required","length","schema", …) anderror.constraint("unique", …) say where an error came from, if you prefer to key the catalogue by them.- Messages from a schema validator such as Zod are already rendered by that library: use its own translation support (in Zod 4,
z.config(z.locales.fr()), for example).
See also
- Errors in the changesets guide
- Showing form errors

