Keelv0.86.0
Docs / Internationalization

Translations with ICU message formatting, plus the Intl formatters that go with them.

import { setTranslations, t } from "@shaferllc/keel/core";

setTranslations({
  en: { "cart.items": "{count, plural, =0 {Your cart is empty} one {# item} other {# items}}" },
  fr: { "cart.items": "{count, plural, =0 {Panier vide} one {# article} other {# articles}}" },
});

t("cart.items", { count: 3 }); // "3 items" — in the request's locale

There is no dependency here, and there doesn't need to be. Intl ships with every modern runtime — Node and Cloudflare Workers both carry the full ICU data — so plurals, currencies, dates, and relative times are the platform's job. What Keel adds is the message parser on top, which is the part Intl doesn't do.

Setting it up

Register translations once (in a service provider), and add the middleware that works out each request's locale:

import { setI18n, I18nManager, setTranslations, detectLocale, HttpKernel } from "@shaferllc/keel/core";

export class I18nServiceProvider extends ServiceProvider {
  boot(): void {
    setI18n(new I18nManager({ defaultLocale: "en" }));

    setTranslations({
      en: await import("../resources/lang/en.json", { with: { type: "json" } }).then((m) => m.default),
      fr: await import("../resources/lang/fr.json", { with: { type: "json" } }).then((m) => m.default),
    });

    this.app.make(HttpKernel).use(detectLocale());
  }
}

Now t() works anywhere in the request — a controller, a view, a transformer — without threading a locale through every call.

Translation files

Nested objects and flat dot-keys are the same thing, and you can mix them:

{
  "cart": {
    "items": "{count, plural, one {# item} other {# items}}",
    "empty": "Your cart is empty"
  },
  "checkout.title": "Checkout"
}

Both t("cart.items") and t("checkout.title") resolve.

The message format

The supported ICU subset is the part people actually use.

Interpolation

Hello {name}!

Plurals

{count, plural, =0 {Your cart is empty} one {# item} other {# items}}

# becomes the count, formatted for the locale (1,234 items). An exact =N branch beats the plural category — which is the whole point of =0, because "Your cart is empty" reads better than "0 items".

Categories are the locale's, not English's. French treats 0 and 1 as singular; Polish has one/few/many/other. That's exactly why you write a message rather than count === 1 ? "item" : "items" — that ternary is a bug in most of the world.

Ordinals

{n, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}

→ 1st, 2nd, 3rd, 4th, 11th.

Select

{gender, select, male {He} female {She} other {They}} replied

An unmatched value (or a missing one) takes the other branch.

Numbers, dates, times

{n, number}                 1,234.5
{n, number, percent}        25%
{n, number, integer}        4
{n, number, ::currency/USD} $9.50
{d, date, medium}           Jul 11, 2026
{d, time, short}            3:30 PM

Nesting

Branches are themselves messages, so they nest as deep as you need:

{count, plural,
  =0 {No messages for {name}}
  one {{name} has # message}
  other {{name} has # messages}}

Literal braces

'{' and '}' render as literal braces.

Formatters

Intl, bound to the request's locale:

const l = i18n();

l.formatNumber(1234.5); // "1,234.5"      (de-DE: "1.234,5")
l.formatCurrency(9.5, "USD"); // "$9.50"
l.formatDate(order.createdAt); // "Jul 11, 2026"
l.formatTime(order.createdAt); // "3:30:00 PM"
l.formatRelativeTime(post.publishedAt); // "3 days ago"
l.formatList(["a", "b", "c"]); // "a, b, and c"
l.formatList(names, { type: "disjunction" }); // "a, b, or c"
l.formatPlural(5); // "other"
l.formatDisplayName("fr"); // "French"

formatRelativeTime picks a sensible unit from the distance on its own — seconds, hours, days — or takes one: formatRelativeTime(date, "hour").

These are worth using even in a single-locale app: they're the correct way to render money and dates, and they cost nothing.

Locale detection

detectLocale() works out the request's locale and stashes it, in this order:

  1. a custom resolve(c) you supply
  2. a query param — detectLocale({ query: "lang" })?lang=fr
  3. a cookie — detectLocale({ cookie: "locale" })
  4. the Accept-Language header (turn it off with header: false)
  5. the default locale

Only supported locales are honored, so ?lang=xx can't push the app into a locale you have no translations for — it falls through to the next source.

negotiateLocale() is the header parser on its own, if you want it:

negotiateLocale("fr-CA,fr;q=0.9,en;q=0.8", ["en", "fr"], "en"); // "fr"

It honors q weights and matches fr-CA against a supported fr.

Fallbacks

A key with no translation in the active locale falls back — down a chain, not off a cliff:

  1. the locale itself (es-MX)
  2. its configured fallback (fallbackLocales: { "es-MX": "es" })
  3. its base language (es)
  4. the default locale (en)

So you can ship es fully and es-MX as a handful of regional overrides, and everything else still resolves:

setTranslations({
  es: { greeting: "Hola", chair: "silla" },
  "es-MX": { chair: "banca" }, // just the override
});

i18n("es-MX").t("chair"); // "banca"
i18n("es-MX").t("greeting"); // "Hola" — from `es`

Missing keys

A missing key does not throw. It renders as the key itself (cart.items), so the page still works and the gap is obvious rather than blank. It also fires an i18n.missing event, which is how you find them in production:

listen("i18n.missing", ({ key, locale }) => {
  logger().warn("missing translation", { key, locale });
});

Override what's rendered with the missing option:

new I18nManager({ missing: (key, locale) => `[${locale}:${key}]` });

API reference

t(key, data?)

t(key: string, data?: Record<string, unknown>): string

Translate a key in the current request's locale (or the default, outside a request), formatting its ICU message with data.

i18n(locale?)

i18n(locale?: string): I18n

An I18n for a locale — or, with no argument, the current request's.

I18n

Method Signature
t (key, data?) => string
has (key) => boolean
formatNumber (value, options?: Intl.NumberFormatOptions) => string
formatCurrency (value, currency, options?) => string
formatDate (value, options?: Intl.DateTimeFormatOptions) => string
formatTime (value, options?) => string
formatRelativeTime (value, unit?, options?) => string
formatList (items, options?: Intl.ListFormatOptions) => string
formatPlural (count, options?) => Intl.LDMLPluralRule
formatDisplayName (code, type?) => string
locale the locale code

I18nManager

Method Signature
add (data: TranslationsByLocale) => this
load (...loaders: TranslationLoader[]) => Promise<this>
locale (code?) => I18n
supported () => string[]
defaultLocale the default locale code

new I18nManager(options) — see I18nOptions.

setI18n(manager) / getI18n() / setTranslations(data)

Replace the active manager, read it, or add translations to it.

detectLocale(options?)

detectLocale(options?: DetectLocaleOptions): MiddlewareHandler

Work out the request's locale and stash it for t() / i18n().

negotiateLocale(header, supported, defaultLocale)

negotiateLocale(header: string | null | undefined, supported: string[], defaultLocale: string): string

The Accept-Language parser, standalone.

formatMessage(message, data?, locale?)

formatMessage(message: string, data?: Record<string, unknown>, locale?: string): string

Format an ICU message directly, without a translation lookup.

objectLoader(data)

objectLoader(data: TranslationsByLocale): TranslationLoader — the simplest loader.

Interfaces & types

I18nOptions

{ defaultLocale?, supportedLocales?, fallbackLocales?, missing? }.

DetectLocaleOptions

{ query?, cookie?, header?, resolve? }.

Translations / TranslationsByLocale

A locale's messages (nested or flat), and those keyed by locale.

TranslationLoader

{ load(): Promise<TranslationsByLocale> | TranslationsByLocale }.