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