An internationalization (i18n) engine turns a message key plus some parameters into a finished, human-readable string in the user's language — looking the key up in the active locale, filling in variables, and choosing the right plural form for a count. It is the core of libraries like i18next, FormatJS, and react-intl: the piece that decides cart.items with a count of 1 should read "1 item in your cart" while a count of 3 reads "3 items in your cart", and that a string missing in French quietly falls back to English instead of rendering blank.
You build i18nEngine(config), a factory that reads a nested table of messages and returns three methods: t to translate a key, setLocale to switch languages, and getLocale to read the current one.
type MessageValue =
| string // a finished message, maybe with {placeholders}
| { one: string; other: string; zero?: string } // a plural group
| { [key: string]: MessageValue }; // a nested table, addressed by dot-path
function i18nEngine(config: {
locale: string; // the starting locale
messages: Record<string, MessageValue>; // locale -> nested message tree
fallbackLocale?: string; // tried when a key is missing in `locale`
}): {
t(key: string, params?: Record<string, unknown>): string; // resolve + interpolate
setLocale(next: string): void; // switch the active locale
getLocale(): string; // read the active locale
};
const engine = i18nEngine({
locale: 'en',
fallbackLocale: 'en',
messages: {
en: { greeting: 'Hello, {name}!' },
fr: { greeting: 'Bonjour, {name} !' },
},
});
engine.t('greeting', { name: 'Sam' }); // 'Hello, Sam!'
engine.setLocale('fr');
engine.t('greeting', { name: 'Sam' }); // 'Bonjour, Sam !'
const engine = i18nEngine({
locale: 'en',
messages: {
en: {
cart: {
items: {
one: '{count} item in your cart',
other: '{count} items in your cart',
},
},
},
},
});
engine.t('cart.items', { count: 1 }); // '1 item in your cart'
engine.t('cart.items', { count: 3 }); // '3 items in your cart'
engine.t('cart.missing'); // 'cart.missing' — no such key, so the key comes back
t('cart.items') walks messages[locale].cart.items; a key with no matching path counts as not found.fallbackLocale; if it is missing there too, return the key string itself so the interface is never blank.{ one, other } chooses one when params.count is 1, zero when the count is 0 and a zero form exists, and other in every other case.{name} in the chosen string with params.name; {count} is always available from the plural count. A placeholder with no matching param stays literal — do not throw and do not insert undefined.Unlock the solution & editor
Runnable editor + tests
Solve in the browser with instant Jest feedback.
Detailed solutions
Walkthroughs, edge cases, and complexity notes.
Multi-framework variants
React, Vue, Vanilla, Angular — same question, different stacks.