Skip to content
Warlock.js v4

Custom error messages and i18n

Out of the box seal gives you English messages like "The email must be a valid email". Real apps want their own wording, and often more than one language. Seal has two layers for this: a quick per-rule override for one-off wording, and a translation hook for project-wide control and i18n.

Layer 1 — per-rule override (the quick one)

Section titled “Layer 1 — per-rule override (the quick one)”

Every chain method takes an optional errorMessage as its last argument. It replaces that rule’s default message for this schema only.

import { v, validate } from "@warlock.js/seal";
const schema = v.object({
email: v.string().email("Please enter a valid email address"),
password: v.string().min(8, "Password must be at least 8 characters"),
name: v.string().required("We need your name"),
});
const result = await validate(schema, { email: "nope", password: "x" });
result.errors;
// [{ type: "email", error: "Please enter a valid email address", input: "email" }]
// (firstErrorOnly defaults to true — one message per call)

Reach for this when a single rule in a single schema needs tailored wording. It’s the 90% case.

Layer 2 — the translation hook (project-wide + i18n)

Section titled “Layer 2 — the translation hook (project-wide + i18n)”

For consistent messages across every schema — and for multiple languages — wire configureSeal once at boot. translateRule is called for every failing rule and returns the message string:

import { configureSeal } from "@warlock.js/seal";
import { trans } from "@mongez/localization";
configureSeal({
translateRule: (ruleTranslation) =>
trans(`validation.${ruleTranslation.rule.name}`, ruleTranslation.attributes),
translateAttribute: (attributeTranslation) =>
trans(
`validation.attributes.${attributeTranslation.attribute}`,
attributeTranslation.context.allValues,
),
});

Now a failing email rule looks up validation.email in your locale files, a failing min looks up validation.min, and so on. Swap the active locale and every message follows — no schema changes.

The ruleTranslation argument carries everything you need to build a message:

  • ruleTranslation.rule.name — the rule type ("email", "min", "required").
  • ruleTranslation.attributes — substitution params: input (the field name), path, value, plus rule-specific values like min.

A typical locale entry uses those params:

en/validation.json
{
"email": "The :input must be a valid email",
"min": "The :input must be at least :min characters",
"required": "The :input field is required"
}

:input resolves to the field name (or its translated display name — see below); :min and friends come from the rule’s metadata.

By default :input is the raw key — "email_address", not "Email Address". To give a field a human label, call .attributes() on the parent object and resolve it through the translateAttribute hook:

const schema = v
.object({ email_address: v.string().email() })
.attributes({ email_address: "Email Address" });

.attributes() stores the display name on the rule context; the translateAttribute hook is what actually swaps it into :input. The two work together — .attributes() alone (with no translation hook wired) leaves the raw key in the message. Wire the hook once and every :input picks up the configured label.

Per-rule override vs translation hook — which to use

Section titled “Per-rule override vs translation hook — which to use”
SituationReach for
One rule in one schema needs custom wordingper-rule errorMessage argument
Consistent wording for a rule across all schemastranslateRule hook
Multiple languagestranslateRule + translateAttribute hooks
Friendly field labels in messages.attributes() + translateAttribute hook

The two layers compose: a per-rule override wins for that specific call site even when a global translateRule is configured, so you can localize globally and still hard-override the odd special case.

Don’t branch on the message — branch on type

Section titled “Don’t branch on the message — branch on type”

When you need to react programmatically to a specific failure, switch on error.type (the stable rule name), never on error.error (the human string, which changes with locale):

const emailFailed = result.errors.find(
(failure) => failure.input === "email" && failure.type === "email",
);