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:
- a custom
resolve(c)you supply - a query param —
detectLocale({ query: "lang" })→?lang=fr - a cookie —
detectLocale({ cookie: "locale" }) - the
Accept-Languageheader (turn it off withheader: false) - 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:
- the locale itself (
es-MX) - its configured fallback (
fallbackLocales: { "es-MX": "es" }) - its base language (
es) - 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 }.