Skip to main content
zerotal

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 lazy i18n singleton (a Translator loaded from your catalogs).
  • onBooting — configures and registers LocaleMiddleware via app.useOnce(), so every request resolves a locale and gains ctx.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)
});
FieldRequiredDefaultDescription
defaultLocaleno"en"Locale used when no resolver matches.
fallbackLocalenodefaultLocaleLocale consulted when a key is missing in the active locale.
supportedLocalesno[defaultLocale]Locales resolvers may return; others fall back to default.
resolversnoquery, cookie, accept-headerRequest resolvers, tried in order (see below).
queryKeyno"lang"Query-string key for the query resolver.
cookieKeyno"locale"Cookie name for the cookie resolver.
loadPathnoDirectory of <locale>.json catalogs, loaded at boot.
catalogsnoIn-memory catalogs, merged over anything from loadPath.

TipI18nConfig() is optional. You can instead write a plain object with satisfies I18nConfigShape for 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 second var __ 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 :name are both replaced from the replacements object.
  • Pluralization — pipe-separated segments are chosen by count: two segments are singular | plural (count === 1 → first); three or more are zero | 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):

ResolverSource
query?lang=fr (key configurable via queryKey)
cookielocale=fr cookie (name configurable via cookieKey)
accept-headerAccept-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 }));
}

NoteI18nContext.run() only affects the Lang facade and __() inside its callback. ctx.__() and ctx.locale were 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");

NoteresolveLocale and parseAcceptLanguage are 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:

MemberSignatureDescription
ctx.localestringThe locale resolved for this request.
ctx.__()__(text: string, replacements?: Replacements, locale?: string): stringTranslate using the request locale.

Lang facade — the i18n binding (a Translator):

MethodSignatureDescription
Lang.translate(text: string, replacements?: Replacements, locale?: string): stringTranslate; an untranslated string returns itself.
Lang.has(text: string, locale?: string): booleanWhether the string exists in the active or fallback locale.
Lang.localesstring[]Loaded locales.
Lang.addCatalog(locale: string, messages: Messages): voidMerge messages into a locale (tooling / tests).

Helpers & context — importable from @zerotal/i18n:

ExportSignatureDescription
__(text: string, replacements?: Replacements, locale?: string): stringGlobal translate; reads the active locale.
I18nContext.run<T>(locale: string, cb: () => T): TRun cb with locale active.
I18nContext.current(): string | undefinedActive locale, or undefined outside a request.
resolveLocale(request: Request, config: I18nConfigShape): stringResolve 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 LocaleMiddleware resolves the request locale.
  • Cookies — persist a visitor's locale choice.
  • View — use __() inside server-rendered JSX.