Internationalization
Request-scoped localization: resolve each visitor's locale automatically, then translate with interpolation, pluralization, and fallback — without threading the locale through every function call.
The string you pass to __() is the English sentence, not a name for it:
__("Email"); // not __("auth.email")
English is the source language, so the source text is the key. That one
decision removes the en.json file, the naming argument, and the class of bug
where a screen ships reading auth.email because somebody mistyped a key.
Getting Started
# in your project root
bun add @zerotal/i18n
Register the provider
Add I18nProvider to the providers array in bootstrap/providers.ts:
// bootstrap/providers.ts
import { I18nProvider } from "@zerotal/i18n";
const providers = [
// …your other providers
I18nProvider,
];
export default providers;
Registering the provider switches on the following:
onRegister— binds the translation service as the lazyi18nsingleton (aTranslatorloaded from your catalogs).onBooting— configures and registersLocaleMiddlewareviaapp.useOnce(), so every request resolves a locale and gainsctx.locale+ctx.__().
Configuration
Create config/i18n.ts. The I18nConfig() helper fills in sensible defaults, so
you only set what differs:
// config/i18n.ts
import { I18nConfig } from "@zerotal/i18n";
import { env } from "zerotal";
export default I18nConfig({
defaultLocale: env("APP_LOCALE", "en"),
fallbackLocale: env("APP_FALLBACK_LOCALE", "en"),
supportedLocales: ["en", "fr"],
resolvers: ["query", "cookie", "accept-header"], // tried in order
queryKey: "lang", // ?lang=fr
cookieKey: "locale", // locale=fr cookie
loadPath: "resources/lang", // <locale>.json files (optional)
});
| Field | Required | Default | Description |
|---|---|---|---|
defaultLocale | no | "en" | Locale used when no resolver matches. |
fallbackLocale | no | defaultLocale | Locale consulted when a key is missing in the active locale. |
supportedLocales | no | [defaultLocale] | Locales resolvers may return; others fall back to default. |
resolvers | no | query, cookie, accept-header | Request resolvers, tried in order (see below). |
queryKey | no | "lang" | Query-string key for the query resolver. |
cookieKey | no | "locale" | Cookie name for the cookie resolver. |
loadPath | no | — | Directory of <locale>.json catalogs, loaded at boot. |
catalogs | no | — | In-memory catalogs, merged over anything from loadPath. |
Tip —
I18nConfig()is optional. You can instead write a plain object withsatisfies I18nConfigShapefor the same type-checking — but then every field is required, since the helper is what supplies the defaults above.
List your source language in supportedLocales, but write no catalog for it:
resources/lang/
├── fr.json
└── es.json
There is no en.json, and adding one would only map 200 English strings to
themselves. An unmatched lookup returns the key, and the key is the English.
Basic usage
LocaleMiddleware resolves the locale for every request and exposes ctx.locale
and ctx.__() on the request context:
// in a controller
async show(ctx: HttpContext) {
ctx.__("Hello, {name}!", { name: "Alice" }); // active locale
ctx.__("Hello, {name}!", { name: "Alice" }, "fr"); // explicit locale
return ctx.json({ locale: ctx.locale });
}
Outside a controller — in a service, job, or view — call __(). It reads the
active request locale from I18nContext, so no locale is passed around, and
it needs no import: I18nProvider puts it on globalThis when it boots.
// anywhere in the request's async tree — no import line
__("Sign in");
It is installed at boot rather than at module load because that is the moment there is a translator behind it. A global resolving against an unbound container would answer every string with itself, which reads as "not translated yet" rather than as "i18n is not installed".
The declaration that types it ships with the package, so an editor completes it
and a wrong argument still fails the build. __ also remains a named export,
which is what a library — unable to assume an application has booted — should
import:
import { Lang, __ } from "@zerotal/i18n";
Lang.translate("Sign in"); // the facade, when you want the instance
Translating in the browser
A React interface cannot reach the server's Translator, so the active locale's
catalog travels to it as a shared Inertia prop:
// bootstrap/app.ts
import { share } from "@zerotal/inertia";
import fr from "../resources/lang/fr.json";
const CATALOGS: Record<string, Record<string, unknown>> = { fr };
share("locale", () => activeLocale());
// `{}` for the source language: every string resolves to itself.
share("messages", () => CATALOGS[activeLocale()] ?? {});
Client-side, keep the catalog in module state and hand it to the translator from
resolve(). Inertia calls resolve with the incoming page before it swaps
the component in, which is what makes the first render after a language change
come out in the new language:
// resources/js/app.tsx
createInertiaApp({
resolve: async (name, incoming) => {
syncTranslations(incoming);
return (await pages[name]!()).default;
},
// …
});
Have that module install the global too — the browser has no provider to boot,
so it does it itself, and importing the module for syncTranslations is what
guarantees the assignment has run:
// resources/js/lib/i18n.ts
(globalThis as { __?: typeof __ }).__ = __;
A component then calls it with no import and no hook:
// resources/js/pages/login.tsx
<TextField label={__("Email")} type="email" />
Note — declare no second ambient
__for the browser. An app is one TypeScript program, so@zerotal/i18n's declaration already covers these call sites; a secondvar __is a duplicate identifier, not an override.
Note — do not sync from
router.on("navigate"). That event fires after the component swap, so the first render of a new page still carries the previous locale's catalog — visible as one flash of the old language every time someone switches.
Because it is a plain function, __() also works in the places a hook cannot go:
module-level option arrays, Page.layout assignments, and helpers that never
receive props. Those are exactly the spots that tend to ship untranslated.
Note — module state is per-tab in a browser, which is what makes this safe. Under Inertia SSR the same module is shared by concurrent requests, so an app that adds SSR must move the catalog into per-render context instead.
Writing a catalog
A catalog maps the English string to its translation. It is flat, because the keys are sentences rather than paths:
// resources/lang/fr.json
{
"Sign in": "Se connecter",
"Forgot your password?": "Mot de passe oublié ?",
"Hello, {name}!": "Bonjour, {name} !",
"The {field} field is required.": "Le champ {field} est obligatoire.",
"no apples|one apple|{count} apples": "aucune pomme|une pomme|{count} pommes"
}
A key is looked up flat first, then as a dot-path. Flat has to win, because an
English sentence contains dots that are punctuation: "Signed out." must not be
split into a Signed out → `` lookup. Nested catalogs still resolve, so an
existing app can move over one screen at a time.
Interpolation, pluralization, fallback
- Interpolation —
{name}and:nameare both replaced from the replacements object. - Pluralization — pipe-separated segments are chosen by
count: two segments aresingular | plural(count === 1→ first); three or more arezero | one | many(0→ first,1→ second, otherwise last). - Fallback — a string missing in the active locale is looked up in
fallbackLocale; if it is still missing, the key is used as the message — and is then interpolated and pluralized like any other hit.
That last point is what lets the source language skip having a catalog:
__("Hello, {name}!", { name: "Alice" }); // "Hello, Alice!" — with no en.json
__("{count} apple|{count} apples", { count: 5 }); // "5 apples"
A string nobody has translated yet renders as the English that was typed, on the day it was typed, in every locale. Untranslated is not the same as broken.
Two English strings, one translation
Identical English collapses to one catalog entry. Usually that is the point — "Sign in" on the button and "Sign in" in the nav want the same translation, and under dotted keys they were two entries that could drift apart.
Where it bites is a word doing two jobs. "Unassigned" describing a count of issues and "Unassigned" describing a missing person are one key here, and a language that renders them differently cannot express both. The fix is to make the English say what it means:
__("Unassigned issues"); // the dashboard count
__("Unassigned"); // the person who is not there
If two uses of a word need two translations, they usually needed two different English strings as well — the ambiguity was there before the translator found it.
Strings that are not sentences
Enum values and column names cannot be passed to __() — in_progress is not
English. Map them to English first, then translate the result:
const STATUS_LABEL: Record<string, string> = {
backlog: "Backlog",
in_progress: "In progress",
done: "Done",
};
__(STATUS_LABEL[issue.status] ?? issue.status);
The ?? issue.status matters: a status added server-side renders as its raw
name rather than vanishing from the page.
Locale resolution
resolveLocale(request, config) tries each configured resolver in order and only
returns a value listed in supportedLocales (otherwise defaultLocale):
| Resolver | Source |
|---|---|
query | ?lang=fr (key configurable via queryKey) |
cookie | locale=fr cookie (name configurable via cookieKey) |
accept-header | Accept-Language, by quality; fr-FR falls back to fr |
Which resolvers should I list? Order them most-explicit first:
query— useful for a one-off preview link (?lang=fr) or language switcher, but it doesn't persist. Put it first so it can override the others.cookie— the choice that sticks. List it when you let users pick a language and persist it (see Overriding the locale).accept-header— the visitor's browser preference; a sensible default when no explicit choice has been made. List it last as the fallback.
Overriding the locale
The locale is resolved once, when LocaleMiddleware runs, and is then fixed for
the rest of the request — so ctx.locale and ctx.__() always reflect what the
resolvers chose. There is no setLocale(). To change the language, do one of two
things.
Persist a user's choice by writing the locale cookie; the cookie resolver
picks it up on every subsequent request:
// in a controller — save the visitor's language choice
async setLocale(ctx: HttpContext) {
const { locale } = await ctx.body<{ locale: string }>();
const headers = new Headers({ Location: ctx.header("Referer") ?? "/" });
headers.append(
"Set-Cookie",
`locale=${locale}; Path=/; Max-Age=${365 * 86400}; SameSite=Lax`,
);
ctx.response = new Response(null, { status: 303, headers });
}
Override within the current request — e.g. to honour a locale stored on the
user — by running code inside I18nContext.run(). The Lang facade and __()
helper use the supplied locale for the duration of the callback:
// in a controller
import { I18nContext, __ } from "@zerotal/i18n";
async show(ctx: HttpContext) {
const user = ctx.user as { name?: string; locale?: string } | undefined;
const locale = user?.locale ?? ctx.locale;
const greeting = I18nContext.run(locale, () =>
__("Hello, {name}!", { name: user?.name }),
);
return ctx.view(DashboardPage({ greeting }));
}
Note —
I18nContext.run()only affects theLangfacade and__()inside its callback.ctx.__()andctx.localewere bound by the middleware and stay on the request's resolved locale. For a persistent change, set the cookie above.
Rendering in someone else's language
A queue job has no request, so there is no ambient locale to read — and the one belonging to whoever triggered the job was never the right answer anyway. Mail should arrive in the language of the person opening it, so pass the locale explicitly as the third argument:
// app/notifications/IssueAssignedNotification.ts
toMail(notifiable: Notifiable): MailMessage {
const recipient = notifiable as { name?: string; locale?: string | null };
const locale = recipient.locale ?? undefined;
return new MailMessage()
.subject(__("{actor} assigned you an issue", { actor: this.assignedBy }, locale))
.greeting(__("Hello {name},", { name: recipient.name ?? "" }, locale));
}
Translating validation messages
Write the message as you want to read it, and translate it where you build the error response:
// resources/lang/fr.json
{
"The {field} field is required.": "Le champ {field} est obligatoire.",
"The {field} must be a valid email address.": "Le champ {field} doit être une adresse e-mail valide."
}
// in a controller — translate a validation message yourself
__("The {field} field is required.", { field: "email" });
See Validator for how the validator itself reports errors.
Using __ in JSX views
// app/views/PostCard.tsx
import { __ } from "@zerotal/i18n";
export function PostCard({ post }: { post: Post }) {
return (
<div>
<h2>{post.title}</h2>
<p>{__("{count} comment|{count} comments", { count: post.commentCount })}</p>
<a href={`/posts/${post.slug}`}>{__("Read more")}</a>
</div>
);
}
__() reads the active locale from I18nContext (async local storage) — no props
threading needed.
Errors
I18nError (E_I18N) is the base; CatalogLoadError (E_I18N_CATALOG_LOAD) is
thrown when a catalog file exists but contains malformed JSON. Both extend
ZerotalError.
Testing
Set your suite up once as described in Testing. Two tests earn their place.
Assert the translated string. Asserting the English proves nothing, since the English is what a completely missing catalog returns:
// tests/i18n/catalogues.test.ts
import { test, expect } from "bun:test";
import { __ } from "@zerotal/i18n";
test("renders the French cart message", () => {
expect(__("Your cart is empty", {}, "fr")).toBe("Votre panier est vide");
});
test("substitutes replacements", () => {
expect(__("{n} items", { n: 3 }, "fr")).toContain("3");
});
Report which strings a locale is missing. There is no en.json to compare
against, so the source is the source: scan it for the strings actually passed to
__() and check each locale covers them. Unlike a key-parity test, this also
catches catalog entries left behind by deleted screens:
// tests/i18n/coverage.test.ts
import { test, expect } from "bun:test";
import { loadCatalogs } from "@zerotal/i18n";
test("reports the strings each locale still needs", async () => {
const used = new Set<string>();
const glob = new Bun.Glob("**/*.{ts,tsx}");
for (const file of glob.scanSync({ cwd: "./app", onlyFiles: true })) {
const source = await Bun.file(`./app/${file}`).text();
for (const [, text] of source.matchAll(/\b__\(\s*"([^"\\]+)"/g)) used.add(text!);
}
const catalogs = await loadCatalogs("./resources/lang");
for (const [locale, messages] of Object.entries(catalogs)) {
const missing = [...used].filter((text) => !(text in messages));
expect(missing, `locale: ${locale}`).toEqual([]);
}
});
Note — start this as a report rather than an assertion if you are adding a language to an existing app: a failing list of 200 strings on day one gets the test deleted, not the strings translated.
Locale resolution is a separate concern from translation, and fails separately — a correct catalogue served under the wrong locale looks like a missing translation:
// tests/http/locale.test.ts
const res = await app.get("/", { "Accept-Language": "fr-CA,fr;q=0.9" });
res.assertSee("Votre panier est vide");
Note —
resolveLocaleandparseAcceptLanguageare exported and pure, so a header you are unsure about can be checked directly rather than through a request.
References
Request context — added by LocaleMiddleware:
| Member | Signature | Description |
|---|---|---|
ctx.locale | string | The locale resolved for this request. |
ctx.__() | __(text: string, replacements?: Replacements, locale?: string): string | Translate using the request locale. |
Lang facade — the i18n binding (a Translator):
| Method | Signature | Description |
|---|---|---|
Lang.translate | (text: string, replacements?: Replacements, locale?: string): string | Translate; an untranslated string returns itself. |
Lang.has | (text: string, locale?: string): boolean | Whether the string exists in the active or fallback locale. |
Lang.locales | string[] | Loaded locales. |
Lang.addCatalog | (locale: string, messages: Messages): void | Merge messages into a locale (tooling / tests). |
Helpers & context — importable from @zerotal/i18n:
| Export | Signature | Description |
|---|---|---|
__ | (text: string, replacements?: Replacements, locale?: string): string | Global translate; reads the active locale. |
I18nContext.run | <T>(locale: string, cb: () => T): T | Run cb with locale active. |
I18nContext.current | (): string | undefined | Active locale, or undefined outside a request. |
resolveLocale | (request: Request, config: I18nConfigShape): string | Resolve a request's locale per config. |
See Configuration for the config/i18n.ts fields.
Next steps
- Validator — pair form validation with translated messages.
- Middleware — how
LocaleMiddlewareresolves the request locale. - Cookies — persist a visitor's locale choice.
- View — use
__()inside server-rendered JSX.