Skip to content

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.

ts
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:, =N exact matches, and the # token are implemented via Intl.PluralRules and covered by tests. See ICU MessageFormat coverage.
  • [x] Message-extraction tooling shipped (#141) — an optional, build-tool-agnostic extractor (@bquery/bquery/i18n/extract + the bquery-i18n CLI) 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/extractextractFromSource, 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.

FeatureSyntaxStatus
Simple argument{name}✅ Supported
Cardinal plural{n, plural, one {…} other {…}}✅ Via Intl.PluralRules (locale-aware)
Plural categorieszero / 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 argumentssub-messages contain {name} / #✅ Supported
The # tokenlocale-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 argsNot supported — render around translations

Known limitation: every plural / select must include an other case (the ICU fallback). When Intl.PluralRules is unavailable for a locale, plural selection degrades to a simple one / other rule rather than throwing.

ts
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.

sh
# 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 --prune

Programmatic use (e.g. from a custom build step):

ts
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.

ts
function createI18n(config: I18nConfig): I18nInstance;

I18nConfig

ts
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

ts
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

ts
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

ts
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.

ts
type TranslateParams = Record<string, string | number>;
ts
// 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.

ts
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:

ts
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:

ts
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:

ts
const locale = detectLocale({
  available: ['en', 'de', 'fr'],
  cookieName: 'lang',
  storageKey: 'locale',
  fallback: 'en',
});

Detection order is:

  1. Cookie (cookieName)
  2. Local storage (storageKey)
  3. <html lang>
  4. navigator.languages / navigator.language

isRTL()

Use isRTL() to derive layout direction from a locale tag:

ts
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.

ts
console.log(i18n.$locale.value); // 'en'

i18n.$locale.value = 'de';
// All tc() computed values now recompute

loadLocale() — 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.

ts
type LocaleLoader = () => Promise<LocaleMessages | { default: LocaleMessages }>;
ts
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.

ts
await i18n.ensureLocale('fr');
i18n.$locale.value = 'fr'; // Now safe to use

Full lazy-loading workflow:

ts
// 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.

ts
const enMessages = i18n.getMessages('en');
// { greeting: 'Hello, {name}!', items: '...', nav: { home: 'Home', about: 'About' } }

const unknownMessages = i18n.getMessages('xx');
// undefined

mergeMessages()

Deep-merges additional messages into an existing locale. Useful for plugins or feature modules that add their own translation keys.

ts
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.

ts
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.

ts
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.

ts
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()

ts
function formatNumber(value: number, locale: string, options?: NumberFormatOptions): string;
ts
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()

ts
function formatDate(value: Date | number, locale: string, options?: DateFormatOptions): string;
ts
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()

ts
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()

ts
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()

ts
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()

ts
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

ts
type NumberFormatOptions = Intl.NumberFormatOptions & {
  /** Override the locale for this specific formatting call. */
  locale?: string;
};

DateFormatOptions

ts
type DateFormatOptions = Intl.DateTimeFormatOptions & {
  /** Override the locale for this specific formatting call. */
  locale?: string;
};

Full Example

ts
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; use tc() when you want a reusable computed translation signal.
  • negotiateLocale() requires you to pass available locales; do not feed it the user's navigator.languages directly without filtering.
  • Message merges reject prototype-pollution keys (__proto__, constructor, prototype).
  • formatRelativeTime requires a numeric value plus a unit — it does not accept ISO strings.
  • isRTL(locale) is a sync utility; do not call it inside an effect() that depends on currentLocale.value unless 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 manual String.prototype.split for grapheme/word segmentation in tooltips and previews.

Testing this module

  • @bquery/bquery/testing ships mockI18n() for deterministic locale state in tests.
  • Snapshot test formatList / formatDisplayName output with a fixed locale to keep diffs reviewable.
  • Forms — translated validation messages.
  • A11yprefersReducedMotion, prefersReducedTransparency, locale-aware focus order.
  • View — bind translated strings via bq-text="t('key')".

Version history

  • 1.15.0graduated 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 helpers defineMessages / formatMessage, and optional message-extraction tooling (@bquery/bquery/i18n/extract + the bquery-i18n CLI).
  • 1.14.0negotiateLocale, detectLocale, isRTL, formatRelativeTime, formatList, formatDisplayName, segment.

Released under the MIT License.