Skip to content

i18n

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, formatDate, formatNumber } 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

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

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.