Skip to content

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.meta holds the values for the placeholders: { count: 2 }, { number: 13 }.
  • Intl.PluralRules picks 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", …) and error.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 ​