i18n
What's new in 1.14.0
i18n gained negotiateLocale, detectLocale, isRTL, formatRelativeTime, formatList, formatDisplayName, and segment in 1.14.0. See the 1.14.0 release notes.
The i18n module provides reactive locale state, interpolation, pluralization, lazy locale loading, and Intl-based formatting. Locale changes propagate automatically through reactive translations.
import {
createI18n,
detectLocale,
formatDate,
formatDisplayName,
formatList,
formatNumber,
formatRelativeTime,
isRTL,
negotiateLocale,
segment,
} from '@bquery/bquery/i18n';Stability
i18n has been Beta, and its surface expanded in 1.14.0 (negotiateLocale, detectLocale, isRTL, formatRelativeTime, formatList, formatDisplayName, segment). Two gaps blocked both stability and adoption: there was no message-extraction tooling, and the extent of ICU MessageFormat coverage was unspecified. The work to graduate it is tracked in #141: freeze the formatting/locale API for one minor cycle, ship an optional extraction tool, and publish a precise ICU coverage statement with tests. It graduated to Stable in 1.15.0, with the surface frozen under the no-breaking-changes-between-minors contract.
Exit criteria
- [x] Public surface frozen for one minor — see Frozen surface reference below; no breaking changes land during the freeze. ICU coverage,
defineMessages/formatMessage, and the extraction tool are additive. - [x] ICU MessageFormat coverage documented and tested (#141) —
plural,selectordinal,select, nested arguments,offset:,=Nexact matches, and the#token are implemented viaIntl.PluralRulesand covered by tests. See ICU MessageFormat coverage. - [x] Message-extraction tooling shipped (#141) — an optional, build-tool-agnostic extractor (
@bquery/bquery/i18n/extract+ thebquery-i18nCLI) scans source and emits/merges catalogs without overwriting translations. The zero-build runtime path is unchanged. See Message extraction. - [x] Lazy-loading of catalogs documented — see Locale Management (
loadLocale()/ensureLocale()) below. - [x] Surface frozen (no breaking changes) — committed under the Stable contract from 1.15.0.
Frozen surface reference (1.15.0)
The frozen public surface of @bquery/bquery/i18n:
- Instance:
createI18n→{ $locale, t, tc, n, d, loadLocale, ensureLocale, getMessages, mergeMessages, availableLocales }. - Formatting:
formatNumber,formatDate,formatRelativeTime,formatList,formatDisplayName,segment. - Locale:
negotiateLocale,detectLocale,isRTL. - Authoring helpers (new in 1.15.0, additive):
defineMessages,formatMessage. - Extraction tooling (new in 1.15.0, additive, separate entry):
@bquery/bquery/i18n/extract→extractFromSource,mergeCatalog,extractFiles,expandGlobs,flatten,unflatten,runExtractCli.
ICU MessageFormat coverage
bQuery commits to the following ICU MessageFormat subset as a documented, stable contract. ICU syntax is detected automatically — a message using a typed argument (plural / selectordinal / select) is routed through the locale-aware ICU formatter; plain {name} interpolation and the legacy singular | plural pipe form keep working unchanged.
| Feature | Syntax | Status |
|---|---|---|
| Simple argument | {name} | ✅ Supported |
| Cardinal plural | {n, plural, one {…} other {…}} | ✅ Via Intl.PluralRules (locale-aware) |
| Plural categories | zero / one / two / few / many / other | ✅ Whatever the locale defines |
| Exact match | {n, plural, =0 {…} other {…}} | ✅ =N wins over the category |
| Offset | {n, plural, offset:1 one {…} other {…}} | ✅ # formats n − offset |
| Ordinal plural | {n, selectordinal, one {#st} other {#th}} | ✅ Via Intl.PluralRules({ type: 'ordinal' }) |
| Select | {gender, select, male {…} female {…} other {…}} | ✅ Exact-key selection |
| Nested arguments | sub-messages contain {name} / # | ✅ Supported |
The # token | locale-formatted plural value | ✅ Supported (with offset) |
| Apostrophe escaping | '{', '}', '#', '' | ✅ Supported |
Inline number / date / time skeletons | {x, number, currency} | ❌ Not supported — use n() / d() |
| Tag / rich-text formatting | <b>…</b> markup args | ❌ Not supported — render around translations |
Known limitation: every
plural/selectmust include anothercase (the ICU fallback). WhenIntl.PluralRulesis unavailable for a locale, plural selection degrades to a simpleone/otherrule rather than throwing.
import { defineMessages, formatMessage } from '@bquery/bquery/i18n';
const messages = defineMessages({
cart: { items: '{count, plural, one {# item} other {# items}}' },
});
formatMessage(messages.cart.items, { count: 1 }); // '1 item'
formatMessage(messages.cart.items, { count: 5 }); // '5 items'
formatMessage('{place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}', { place: 3 }); // '3rd'defineMessages() is an identity helper: it returns the catalog unchanged while preserving its literal shape for type inference, and it is the anchor the extraction tool recognises. formatMessage() formats a single message string outside of a full instance (handy for tests and one-offs); inside an app, prefer i18n.t() / i18n.tc(), which carry the active locale automatically.
Message extraction (optional)
The extractor is optional and build-tool-agnostic — importing it is never required to use i18n at runtime, preserving the zero-build path. It scans source for defineMessages({ … }) catalogs (keys and default strings) and t('key') / tc('key') calls (keys with empty defaults), then emits or merges a JSON catalog.
# Merge newly found keys into an existing catalog (keeps translations):
bquery-i18n extract "src/**/*.ts" --out locales/en.json
# Strictly prune keys no longer present in source:
bquery-i18n extract "src/**/*.ts" --out locales/en.json --pruneProgrammatic use (e.g. from a custom build step):
import { extractFromSource, mergeCatalog } from '@bquery/bquery/i18n/extract';
const found = extractFromSource(sourceCode);
const { catalog, added, kept, removed } = mergeCatalog(existingCatalog, found, { prune: false });Merge guarantees: existing translations are never overwritten; new keys are added (with their defineMessages default, or '' for call-only keys); stale keys are preserved unless --prune is passed. The merged catalog is emitted as nested, sorted JSON ready for loadLocale(() => import('./locales/en.json')).
Creating an i18n Instance
createI18n()
Creates a reactive internationalization instance that manages translations, locale switching, lazy loading, and Intl-based formatting.
function createI18n(config: I18nConfig): I18nInstance;I18nConfig
type I18nConfig = {
/** The initial locale. */
locale: string;
/** Translation messages keyed by locale. */
messages: Messages;
/** Fallback locale when a key is missing in the active locale. */
fallbackLocale?: string;
};Messages
type Messages = {
[locale: string]: LocaleMessages;
};
type LocaleMessages = {
[key: string]: string | LocaleMessages;
};Messages support nested keys. Nested messages can be accessed with dot notation (e.g., 'nav.home').
Example
const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
messages: {
en: {
greeting: 'Hello, {name}!',
items: '{count} item | {count} items',
nav: {
home: 'Home',
about: 'About',
},
},
de: {
greeting: 'Hallo, {name}!',
items: '{count} Eintrag | {count} Einträge',
nav: {
home: 'Startseite',
about: 'Über uns',
},
},
},
});The I18nInstance API
I18nInstance
interface I18nInstance {
/** Reactive locale signal. Assign to switch locales. */
$locale: Signal<string>;
/** Translate a message key with optional parameters. */
t: (key: string, params?: TranslateParams) => string;
/** Reactive translation — returns a computed signal that updates on locale change. */
tc: (key: string, params?: TranslateParams) => ReadonlySignal<string>;
/** Register a lazy-loader for a locale. */
loadLocale: (locale: string, loader: LocaleLoader) => void;
/** Trigger the lazy-load for a locale and wait for it to complete. */
ensureLocale: (locale: string) => Promise<void>;
/** Locale-aware number formatting. */
n: (value: number, options?: NumberFormatOptions) => string;
/** Locale-aware date formatting. */
d: (value: Date | number, options?: DateFormatOptions) => string;
/** Get all messages for a specific locale. */
getMessages: (locale: string) => LocaleMessages | undefined;
/** Deep-merge additional messages into a locale. */
mergeMessages: (locale: string, messages: LocaleMessages) => void;
/** List all locales that have messages loaded. */
availableLocales: () => string[];
}Translation
t() — Static Translation
Translates a message key using the current locale. Supports parameter interpolation and pluralization.
type TranslateParams = Record<string, string | number>;// Simple translation
i18n.t('greeting', { name: 'Ada' }); // 'Hello, Ada!'
// Nested key access
i18n.t('nav.home'); // 'Home'
// Pluralization (pipe-separated)
i18n.t('items', { count: 1 }); // '1 item'
i18n.t('items', { count: 5 }); // '5 items'
i18n.t('items', { count: 0 }); // '0 items'Pluralization rules: Messages with | are split into forms. The count parameter determines which form is selected:
- 2 forms (
one | many):count === 1→ first form; otherwise → second form - 3 forms (
zero | one | many):count === 0→ first form;count === 1→ second form; otherwise → third form - More than 3 forms:
count === 0→ first form;count === 1→ second form; otherwise → last form
tc() — Reactive Translation
Returns a ReadonlySignal<string> that automatically updates when the locale changes. Use this in effects, computed values, or view bindings.
const greeting = i18n.tc('greeting', { name: 'Ada' });
console.log(greeting.value); // 'Hello, Ada!'
// Changing locale updates the translation reactively
i18n.$locale.value = 'de';
console.log(greeting.value); // 'Hallo, Ada!'Usage with effects:
import { effect } from '@bquery/bquery/reactive';
const title = i18n.tc('nav.home');
effect(() => {
document.title = title.value;
});
// When the locale changes, the document title updates automatically
i18n.$locale.value = 'de'; // document.title → 'Startseite'Locale Management
negotiateLocale()
Match a prioritized list of requested locales against your supported set:
const locale = negotiateLocale(['de-CH', 'en'], ['en', 'de', 'fr']);
// 'de'The helper prefers exact matches first, then language-only matches, then the provided fallback (or the first available locale).
detectLocale()
Detect the preferred locale from cookies, storage, <html lang>, and browser settings:
const locale = detectLocale({
available: ['en', 'de', 'fr'],
cookieName: 'lang',
storageKey: 'locale',
fallback: 'en',
});Detection order is:
- Cookie (
cookieName) - Local storage (
storageKey) <html lang>navigator.languages/navigator.language
isRTL()
Use isRTL() to derive layout direction from a locale tag:
document.documentElement.dir = isRTL(i18n.$locale.value) ? 'rtl' : 'ltr';isRTL() prefers Intl.Locale text-direction data when available and falls back to a built-in list of well-known RTL languages/scripts when it is not.
$locale — Reactive Locale Signal
The $locale property is a writable Signal<string>. Assigning a new value switches the active locale and triggers all reactive translations.
console.log(i18n.$locale.value); // 'en'
i18n.$locale.value = 'de';
// All tc() computed values now recomputeloadLocale() — Register a Lazy Loader
Registers a loader function for a locale that hasn't been loaded yet. The loader is only invoked when ensureLocale() is called.
type LocaleLoader = () => Promise<LocaleMessages | { default: LocaleMessages }>;i18n.loadLocale('fr', () => import('./locales/fr.json'));
i18n.loadLocale('ja', () => import('./locales/ja.json'));ensureLocale() — Trigger Lazy Loading
Triggers the lazy-load for a locale and returns a Promise that resolves when the messages are ready. Repeated calls for the same locale are cached — the loader runs only once.
await i18n.ensureLocale('fr');
i18n.$locale.value = 'fr'; // Now safe to useFull lazy-loading workflow:
// 1. Register loaders at startup
i18n.loadLocale('fr', () => import('./locales/fr.json'));
i18n.loadLocale('ja', () => import('./locales/ja.json'));
// 2. When user selects a new locale
async function switchLocale(locale: string) {
await i18n.ensureLocale(locale);
i18n.$locale.value = locale;
}
await switchLocale('fr');getMessages()
Returns all messages for a specific locale, or undefined if the locale hasn't been loaded.
const enMessages = i18n.getMessages('en');
// { greeting: 'Hello, {name}!', items: '...', nav: { home: 'Home', about: 'About' } }
const unknownMessages = i18n.getMessages('xx');
// undefinedmergeMessages()
Deep-merges additional messages into an existing locale. Useful for plugins or feature modules that add their own translation keys.
i18n.mergeMessages('en', {
settings: {
title: 'Settings',
theme: 'Theme',
},
});
i18n.t('settings.title'); // 'Settings'availableLocales()
Returns an array of all locales that currently have messages loaded.
console.log(i18n.availableLocales()); // ['en', 'de']Number and Date Formatting
n() — Locale-Aware Number Formatting
Formats a number using Intl.NumberFormat and the current locale.
i18n.n(1234.56);
// 'en' → '1,234.56'
// 'de' → '1.234,56'
i18n.n(0.756, { style: 'percent' });
// 'en' → '76%'
i18n.n(99.99, { style: 'currency', currency: 'EUR' });
// 'de' → '99,99 €'d() — Locale-Aware Date Formatting
Formats a date using Intl.DateTimeFormat and the current locale.
i18n.d(new Date('2026-03-26'));
// 'en' → '3/26/2026'
// 'de' → '26.3.2026'
i18n.d(new Date(), { dateStyle: 'long' });
// 'en' → 'March 26, 2026'
// 'de' → '26. März 2026'
i18n.d(new Date(), { dateStyle: 'full', timeStyle: 'short' });
// 'en' → 'Thursday, March 26, 2026, 2:30 PM'Standalone Formatting Helpers
These functions are available without creating an i18n instance. They accept an explicit locale parameter.
formatNumber()
function formatNumber(value: number, locale: string, options?: NumberFormatOptions): string;import { formatNumber } from '@bquery/bquery/i18n';
formatNumber(1234.56, 'en-US');
// '1,234.56'
formatNumber(1234.56, 'de-DE');
// '1.234,56'
formatNumber(0.85, 'en-US', { style: 'percent' });
// '85%'formatDate()
function formatDate(value: Date | number, locale: string, options?: DateFormatOptions): string;import { formatDate } from '@bquery/bquery/i18n';
formatDate(new Date('2026-03-26'), 'en-US');
// '3/26/2026'
formatDate(new Date('2026-03-26'), 'de-DE', { dateStyle: 'long' });
// '26. März 2026'formatRelativeTime()
formatRelativeTime(-1, 'day', 'en'); // '1 day ago'
formatRelativeTime(3, 'hour', 'en'); // 'in 3 hours'Falls back to a simple English-style string when Intl.RelativeTimeFormat is unavailable.
formatList()
formatList(['apples', 'pears', 'plums'], 'en');
// 'apples, pears, and plums'
formatList(['apples', 'pears'], 'en', { type: 'disjunction' });
// 'apples or pears'Falls back to a simple comma-joined English list when Intl.ListFormat is unavailable.
formatDisplayName()
formatDisplayName('US', 'en', { type: 'region' }); // 'United States'
formatDisplayName('USD', 'en', { type: 'currency' }); // 'US Dollar'Falls back to the original code when Intl.DisplayNames is unavailable.
segment()
segment('hello world', 'en', { granularity: 'word' });
// ['hello', ' ', 'world']Falls back to simple character, whitespace, or sentence splitting when Intl.Segmenter is unavailable.
Type Definitions
NumberFormatOptions
type NumberFormatOptions = Intl.NumberFormatOptions & {
/** Override the locale for this specific formatting call. */
locale?: string;
};DateFormatOptions
type DateFormatOptions = Intl.DateTimeFormatOptions & {
/** Override the locale for this specific formatting call. */
locale?: string;
};Full Example
import { createI18n } from '@bquery/bquery/i18n';
import { effect } from '@bquery/bquery/reactive';
const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
messages: {
en: {
welcome: 'Welcome, {name}!',
items: '{count} item | {count} items',
nav: { home: 'Home', settings: 'Settings' },
},
de: {
welcome: 'Willkommen, {name}!',
items: '{count} Eintrag | {count} Einträge',
nav: { home: 'Startseite', settings: 'Einstellungen' },
},
},
});
// Register lazy locale
i18n.loadLocale('fr', () => import('./locales/fr.json'));
// Static translations
console.log(i18n.t('welcome', { name: 'Ada' })); // 'Welcome, Ada!'
console.log(i18n.t('items', { count: 3 })); // '3 items'
console.log(i18n.t('nav.home')); // 'Home'
// Reactive translations
const title = i18n.tc('nav.home');
effect(() => {
document.title = title.value; // Updates when locale changes
});
// Number and date formatting
console.log(i18n.n(42000)); // '42,000'
console.log(i18n.d(new Date(), { dateStyle: 'long' })); // 'March 26, 2026'
// Switch locale
i18n.$locale.value = 'de';
console.log(i18n.t('welcome', { name: 'Ada' })); // 'Willkommen, Ada!'Notes
- Messages support nested keys accessed with dot notation.
- Repeated locale loads are cached — loaders only execute once.
- Deep message merges are hardened against prototype-pollution keys (
__proto__,constructor,prototype). - The fallback locale is used when a key is missing in the active locale.
tc()returns a computed signal, so it re-evaluates only when the locale actually changes.
Pitfalls and gotchas
t()is reactive inside view bindings and effects because it reads the current locale signal; usetc()when you want a reusable computed translation signal.negotiateLocale()requires you to pass available locales; do not feed it the user'snavigator.languagesdirectly without filtering.- Message merges reject prototype-pollution keys (
__proto__,constructor,prototype). formatRelativeTimerequires a numeric value plus a unit — it does not accept ISO strings.isRTL(locale)is a sync utility; do not call it inside aneffect()that depends oncurrentLocale.valueunless you also read the signal.
Performance notes
- Locale loaders are cached — split large dictionaries into per-feature files and load on demand.
- Use
segment()over manualString.prototype.splitfor grapheme/word segmentation in tooltips and previews.
Testing this module
@bquery/bquery/testingshipsmockI18n()for deterministic locale state in tests.- Snapshot test
formatList/formatDisplayNameoutput with a fixed locale to keep diffs reviewable.
Related modules
- Forms — translated validation messages.
- A11y —
prefersReducedMotion,prefersReducedTransparency, locale-aware focus order. - View — bind translated strings via
bq-text="t('key')".
Version history
- 1.15.0 — graduated to Stable: formatting/locale surface frozen for one minor cycle (#141). ICU MessageFormat coverage (
plural,selectordinal,select, nested args,offset:,=N,#) documented and tested. New authoring helpersdefineMessages/formatMessage, and optional message-extraction tooling (@bquery/bquery/i18n/extract+ thebquery-i18nCLI). - 1.14.0 —
negotiateLocale,detectLocale,isRTL,formatRelativeTime,formatList,formatDisplayName,segment.