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';

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.14.0negotiateLocale, detectLocale, isRTL, formatRelativeTime, formatList, formatDisplayName, segment.

Released under the MIT License.