Localization (i18n)
Localization in Warlock is one resolved locale per request, plus a translation registry and a pair of multi-locale primitives — a validator for input and a resource cast for output. The framework reads the locale once from the incoming request, binds the translation helpers to it, and every downstream layer (t(), the "localized" resource cast) reads from that same value. You rarely pass a locale around by hand.
This page is the concept home: how the locale is actually resolved (the precedence is more than the obvious header), where translations live, how to validate multi-locale input, how to ship per-locale output, and what to do outside the HTTP lifecycle. For an end-to-end worked example — wiring every layer for a real product endpoint — see the Localized responses recipe.
The 30-second look
Section titled “The 30-second look”import { t } from "@warlock.js/core";
// In a controller — locale is already resolved from the request.export const showProduct: RequestHandler = async ({ request, response }) => { const product = await productsRepository.get(request.input("id"));
if (!product) { return response.notFound({ error: t("products.notFound") }); }
// The "localized" resource cast picks the request's locale automatically. return response.success({ product: new ProductResource(product).toJSON() });};Three things made that work: the request resolved a locale at construction, t() was bound to it, and the resource cast read the same locale. The rest of this page explains each.
Locale resolution precedence
Section titled “Locale resolution precedence”This is the part the recipe used to under-document. The Request.locale getter checks three sources, and the very first one is a header that is easy to miss.
flowchart TD
A["translation-locale-code header"] -->|present| OUT["resolved locale"]
A -->|absent| B["locale header"]
B -->|absent| C["?locale query param"]
C -->|absent| D["app.localeCode config<br/>(falls back to 'en')"]
B -->|present| OUT
C -->|present| OUT
D --> OUT
In precedence order:
| # | Source | Example | Read by |
|---|---|---|---|
| 1 | translation-locale-code header | translation-locale-code: ar | request.locale getter (checked first) |
| 2 | locale header | locale: ar | request.localized getter |
| 3 | ?locale query param | ?locale=ar | request.localized getter |
| 4 | app.localeCode config | defaults to "en" | request.getLocaleCode() |
The request resolves the locale like this:
public get locale() { if (this._locale) return this._locale;
return this.header("translation-locale-code") || this.localized;}
public get localized() { if (this._locale) return this._locale;
return (this._locale = this.header("locale") || this.query["locale"]);}
public getLocaleCode(defaultLocaleCode: string = config.key("app.localeCode") || "en") { return this.locale || defaultLocaleCode;}So translation-locale-code wins over everything; locale (header or query) is the fallback; the configured default applies only when no source resolved a value. request.setLocaleCode(code) (or assigning request.locale = code) sets _locale, which short-circuits all of the above for the rest of the request.
The existing Localized responses recipe lists only the
localeheader /?localequery / config default. That is therequest.localizedchain — it omits the higher-prioritytranslation-locale-codeheader. When in doubt, this precedence table is authoritative.
The config key for the default locale
Section titled “The config key for the default locale”The runtime reads config.key("app.localeCode") (see getLocaleCode() above). So the default locale lives in src/config/app.ts under localeCode:
export default { localeCode: "en", // default locale when a request specifies none locales: ["en", "ar"], // allowed locales (used to preload dayjs locale data)};Two distinct keys, don’t conflate them:
| Config key | Type | Purpose |
|---|---|---|
app.localeCode | string (default "en") | the default locale used by getLocaleCode() when the request resolves none |
app.locales | string[] | the allowed locale list; the app config handler preloads dayjs locale data for each non-English entry |
Both fields are declared on the AppConfigurations type as localeCode? and locales?. The config handler at src/config/config-handlers.ts reads config.locales to load dayjs/locale/<code>; the request layer reads app.localeCode for the default.
The translation registry
Section titled “The translation registry”@warlock.js/core re-exports @mongez/localization wholesale (export * from "@mongez/localization" in core’s barrel), so t, trans, groupedTranslations, and setCurrentLocaleCode are all importable from @warlock.js/core or @mongez/localization — same functions either way.
Registering translations
Section titled “Registering translations”Prefer a strict JSON dictionary per module in utils/locales.json (auto-loaded by the framework):
{ "notFound": { "en": "Product not found", "ar": "المنتج غير موجود" }, "errors": { "invalidPrice": { "en": "Invalid price", "ar": "سعر غير صالح" } }}The module folder is the default group, so these keys are
products.notFound and products.errors.invalidPrice. A root $group string
replaces that group. Nested keys are flattened, every dictionary is strictly
validated, and one key cannot be owned by two dictionaries (or by a dictionary
and a static registration). Do not import the file; Warlock loads it.
One file per feature
Section titled “One file per feature”A module’s dictionary grows with every feature, and one shared JSON file is where
parallel work collides. Put each feature’s keys in its own file,
utils/locales/<feature>.json, next to (or instead of) utils/locales.json:
{ "reviewAdded": { "en": "Review added", "ar": "تمت إضافة التقييم" }}Every file of the module is merged into one translation registry when it loads:
in warlock dev, in the production build and in test workers
(loadTestLocales). The default group is still the module (products.reviewAdded),
a root $group still replaces it, and one key can be owned by one file only, so
two files that define the same key fail at load and name both. Web’s per-page
translation tables are built from the same registry and include these keys. Only
utils/locales.json and the files directly inside utils/locales/ are loaded.
Check that every key has every locale
Section titled “Check that every key has every locale”npx warlock checkreports WL9105 for each key missing one of the configured locales, naming the
file and the key, without booting the app. The configured locales are
localeCodes in src/config/app.ts; a localeCodes array in .warlock-check.json
overrides them. When neither names any locale the rule is skipped. Boot fails on
the same gap, so the check moves the error into the pull request.
Migrating from utils/locales.ts
Section titled “Migrating from utils/locales.ts”utils/locales.ts is deprecated. It still loads, and the dev server and
warlock build print one warning that names every such file; warlock doctor
lists them under WL9101. Removal is planned for v6. Run warlock doctor --fix
to convert each file that holds one literal groupedTranslations call:
- into the module’s
utils/locales.json, or - into
utils/locales/legacy.jsonwhenlocales.jsonalready exists and shares no key with the file.
A file with computed keys or other code is left alone and listed as manual work. Comments in the TypeScript source are not carried into the JSON. The legacy registration form is:
import { groupedTranslations } from "@mongez/localization";
groupedTranslations("products", { notFound: { en: "Product not found", ar: "المنتج غير موجود", }, outOfStock: { en: "This product is out of stock", ar: "هذا المنتج غير متوفر", },});The group name becomes the namespace: t("products.notFound") resolves products → notFound → <currentLocale>. Nested groups dot-access naturally (t("products.errors.invalidPrice")).
Reading translations: t() vs trans()
Section titled “Reading translations: t() vs trans()”| Helper | Locale it uses | Use when |
|---|---|---|
t(key, placeholders?) | the current request’s locale | inside a request (controllers, services, resources) |
trans(key, placeholders?) | the process-global “current locale” | outside a request, or when you’ve set the locale manually |
request.trans(key, placeholders?) | this request’s resolved locale (bound at construction) | internal binding t() delegates to; app code should use t() |
Always use t() in app code. The framework binds request.trans/request.t to transFrom.bind(null, localeCode) at request construction, and the core t() helper delegates to the current request’s trans (falling back to the global trans when there is no request). You never pass the locale — the binding carries it.
import { t } from "@warlock.js/core";
return response.unauthorized({ error: t("auth.invalidCredentials") });// locale: ar → "البريد الالكتروني أو كلمة المرور غير صحيحة"// locale: en → "Invalid email or password"Placeholders
Section titled “Placeholders”Both t() and trans() take a placeholders object as the second argument. Define the translation with {name}-style tokens and pass matching keys:
groupedTranslations("products", { insufficientQuantity: { en: "Only {available} items left", ar: "تبقى {available} قطع فقط", },});
t("products.insufficientQuantity", { available: 3 });// en → "Only 3 items left"// ar → "تبقى 3 قطع فقط"Unmatched tokens stay as literals, which makes typos visible rather than silent.
Validation messages are localized for free
Section titled “Validation messages are localized for free”Warlock wires seal’s rule/attribute translation through this same registry. Rule messages resolve from validation.<ruleName> and attribute names from attributes.<field>, both via t() — so they come out in the request’s locale automatically. See Validation for the message keys.
Validating multi-locale input: v.localized()
Section titled “Validating multi-locale input: v.localized()”When a client submits a field in several locales at once (e.g. a product name in both English and Arabic), validate it with v.localized(). @warlock.js/core registers this validator on seal’s v factory at startup.
v.localized(valueValidator?, errorMessage?) expects an array of { localeCode, value } objects. localeCode is a required string; value defaults to v.scalar() unless you pass a stricter inner validator:
import { v, type Infer } from "@warlock.js/seal";
export const createProductSchema = v.object({ // each entry must be { localeCode: string, value: string } name: v.localized(v.string().min(2)), description: v.localized(v.string()).optional(), price: v.number().min(0),});
export type CreateProductSchema = Infer<typeof createProductSchema>;A valid name payload:
{ "name": [ { "localeCode": "en", "value": "Premium Hoodie" }, { "localeCode": "ar", "value": "هودي بريميوم" } ]}This is the input half of the localized pair — it validates the multi-locale shape on the way in. The resource cast below is the output half that collapses that shape back down to a single string per request.
Localized output: the "localized" cast
Section titled “Localized output: the "localized" cast”Cascade stores localized columns as [{ localeCode, value }] arrays — exactly the shape v.localized() validates. The "localized" resource cast collapses that array to the single value matching the current request’s locale on the way out.
import { defineResource } from "@warlock.js/core";
export const ProductResource = defineResource({ schema: { id: "string", name: "localized", description: "localized", price: "number", },});// GET /products/1 with translation-locale-code: ar{ "product": { "id": "1", "name": "هودي بريميوم", "description": "قطن ناعم، مقاس واسع", "price": 49.99 }}How the locale is chosen: the resource reads useRequestStore()?.request?.locale — i.e. the same request.locale getter, so it honors the translation-locale-code header first, then the locale header/query. The matching { localeCode, value } entry’s value is returned. If the stored value is already a plain string (not an array), it passes through unchanged — the gradual-migration case. If no locale resolves, the first entry’s value is used.
There is also a builder form, resource.localized(inputKey?), for resources that build fields imperatively rather than via the schema map. The string cast "localized" and the builder do the same transform.
For the full cast surface ("date", "url", nested resources, array suffixes, etc.), see Resources deep dive.
Explicit locale on a request: request.transFrom()
Section titled “Explicit locale on a request: request.transFrom()”The bound t()/request.trans always use the request’s resolved locale. When you need a different locale for one specific call — render an admin notification in the recipient’s language while the request itself is in another — use request.transFrom(localeCode, key, placeholders?):
request.transFrom("ar", "auth.otpExpired");// → "رمز التحقق منتهي الصلاحية" (regardless of the request's own locale)This is a thin wrapper over transFrom from @mongez/localization. It does not change the request’s locale or re-bind t() — it is a one-shot translation in the locale you name.
Outside a request: setCurrentLocaleCode + trans
Section titled “Outside a request: setCurrentLocaleCode + trans”There is no request context in a CLI command, a scheduled job, or a queue worker — so t() falls back to the process-global locale. Set that locale explicitly, then use trans():
import { setCurrentLocaleCode, trans } from "@warlock.js/core";
setCurrentLocaleCode("ar");
const message = trans("auth.otpExpired");// → "رمز التحقق منتهي الصلاحية"setCurrentLocaleCode is process-global. In a worker that handles jobs for different tenants/locales, set it at the start of each job handler so one job’s locale never leaks into the next. For a single off-locale translation without changing global state, prefer transFrom(localeCode, key) directly.
Validation messages
Section titled “Validation messages”The default seal rule translation looks up validation.<rule> (or the group set in validation.translationGroup) in the translation registry. A translation is used only when every :placeholder in it can be filled from the rule’s parameters; otherwise the schema falls back to seal’s own message, so a raw :placeholder never reaches an API response. Placeholder aliases are mapped for you: :length reads :minLength / :maxLength for the minLength and maxLength rules, and :matchingInput reads :field for match.
This matters when a browser library such as @mongez/react-form is imported on the server: it registers English validation.* defaults in the same process-wide registry. Your own validation.* translations still override them. To take full control, set validation.translationGroup to a group only your app fills, or provide validation.translateRule.
In development, the first time a key’s translation cannot be filled, a warning names the key.
Gotchas
Section titled “Gotchas”translation-locale-codebeats thelocaleheader and?localequery. If a request sets bothtranslation-locale-code: enandlocale: ar, you get English. The recipe’s three-source list omits this header — this page’s precedence table is the authoritative order.- Locale resolves once, lazily, and is then cached on
_locale. The first read ofrequest.locale/request.localizedmemoizes the value. Callingrequest.setLocaleCode("ar")afterward changes_locale, butt()was already bound at construction — so for an already-boundt()to follow a mid-request change is not guaranteed. To translate in a specific locale mid-request, userequest.transFrom("ar", "key")explicitly. - The default-locale config key is
app.localeCode, notapp.locale. The runtime readsconfig.key("app.localeCode").app.locales(plural) is the separate allowed-list used to preloaddayjslocale data — it is not the default. t()/trans()return the key itself when nothing matches.t("products.unknown")returns"products.unknown"(or the fallback locale’s value). Treat an unresolved key as a development bug, not a user-facing string.v.localized()validates an array of{ localeCode, value }, and the"localized"cast expects the same shape. A{ en: "...", ar: "..." }object is neither — convert at the model level if your storage uses that shape.setCurrentLocaleCodeis process-global. Outside a request it is the only lever, but it bleeds across jobs if you forget to set it per-job. Inside a request, never call it — uset().
See also
Section titled “See also”- Localized responses recipe — the end-to-end worked example: resolving the locale, translating errors, the resource cast, and
Intlfor dates/numbers, wired into one product endpoint. - Validation —
v.localized()in context, plus how rule/attribute messages localize through this same registry. - Resources deep dive — the full cast surface, including
"localized"and the.localized()builder. - HTTP request —
request.locale,request.getLocaleCode(),request.setLocaleCode(), andrequest.transFrom().
Notification translation defaults
Section titled “Notification translation defaults”After configuring app.localeCodes, run npx warlock add notifications.
New notification dictionaries include notFound as a string in every configured
locale. English (“Notification not found”) is the explicit fallback for each
locale without a bundled translation, including locale sets without English.
Replace fallback text with your translations before publishing. Existing module
files and existing translation keys are preserved.