ilingo
v6.0.0
Published
This is a lightweight library for translation.
Maintainers
Readme
ilingo 💬
Ilingo is a lightweight library for translation and internationalization. The core's only runtime dependencies are pathtrace and smob; on common workloads it runs 1.6× – 2.3× faster than i18next (benchmarks).
Table of Contents
Installation
npm install ilingo --saveConfiguration
While full localization of an application is a complex subject, swapping out strings in your application for different supported languages/locales is simple. The different locale strings for translation are provided by interacting with the library class instance.
Usage
Basic
Create an instance and set the default locale.
import { Ilingo } from 'ilingo';
const ilingo = new Ilingo({
locale: 'en'
})The default (memory-) store is initialized with a catalog built from the
defineCatalog / defineLocale / defineNamespace / defineTranslations tree helpers.
import {
Ilingo,
MemoryStore,
defineCatalog,
defineLocale,
defineNamespace,
defineTranslations,
} from 'ilingo';
const catalog = defineCatalog([
// locale: de
defineLocale('de', [
// namespace: app
defineNamespace('app', [
defineTranslations({ key: 'Hallo mein Name ist {{name}}' }),
]),
]),
// locale: en
defineLocale('en', [
defineNamespace('app', [
defineTranslations({ key: 'Hello my name is {{name}}' }),
]),
]),
]);
const store = new MemoryStore({ data: catalog });
const ilingo = new Ilingo({
store,
locale: 'en'
});To retrieve text from any of the language files, simply pass the filename/namespace and the access key as the first parameter, separated by a period (.).
After that you can simply access the locale string, as described in the following:
import { Ilingo } from 'ilingo';
const ilingo = new Ilingo({
// ...
});
await ilingo.get({
namespace: 'app',
key: 'key'
});
// Hello my name is {{name}}
await ilingo.get({
namespace: 'app',
key: 'key',
data: {
name: 'Peter'
}
});
// Hello my name is Peter
await ilingo.get({
namespace: 'app',
key: 'key',
data: {
name: 'Peter'
},
locale: 'de'
});
// Hallo mein Name ist PeterParameters
As a template delimiter a mustache like {{}} interpolation is used.
Data properties can be injected as a second argument, e.g.
import { Ilingo, MemoryStore, defineCatalog, defineLocale, defineNamespace, defineTranslations } from 'ilingo';
const ilingo = new Ilingo({
store: new MemoryStore({
data: defineCatalog([
defineLocale('en', [
defineNamespace('app', [
defineTranslations({ age: 'I am {{age}} years old.' }),
]),
]),
]),
})
});
await ilingo.get({
namespace: 'app',
key: 'age',
data: {
age: 18
}
});
// I am 18 yeas oldLocales
The default locale can be modified after initialization:
import { Ilingo } from 'ilingo';
const ilingo = new Ilingo({
// ...
});
await ilingo.get({
namespace: 'app',
key: 'age',
data: {
age: 18
}
});
// I am 18 yeas old
ilingo.setLocale('de');
await ilingo.get({
namespace: 'app',
key: 'age',
data: {
age: 18
}
});
// Ich bin 18 Jahre altIt can also be temporarily overwritten per call, by passing a locale in the
translation context:
import { Ilingo } from 'ilingo';
const ilingo = new Ilingo({
// ...
});
await ilingo.get({
namespace: 'app',
key: 'age',
data: {
age: 18
}
});
// I am 18 yeas old
await ilingo.get({
namespace: 'app',
key: 'age',
data: {
age: 18
},
locale: 'fr'
});
// J'ai 18 ans
await ilingo.get({
namespace: 'app',
key: 'age',
data: {
age: 18
},
locale: 'de'
});
// Ich bin 18 Jahre altLazy
Another option is to add translations on the fly and access them afterwards.
import { Ilingo, MemoryStore, defineCatalog, defineLocale, defineNamespace, defineTranslations } from 'ilingo';
const ilingo = new Ilingo({
store: new MemoryStore({
data: defineCatalog([
defineLocale('en', [
defineNamespace('foo', [
defineTranslations({ bar: 'baz {{param}}' }),
]),
]),
defineLocale('de', [
defineNamespace('foo', [
defineTranslations({ bar: 'boz {{param}}' }),
]),
]),
]),
})
});
await ilingo.get({
namespace: 'foo',
key: 'bar',
data: {
param: 'x'
}
});
// baz x
await ilingo.get({
namespace: 'foo',
key: 'bar',
data: {
param: 'y'
},
locale: 'de'
});
// boz yPluralization
Plural forms are CLDR categories (zero | one | two | few | many | other, other required); the matching form is selected via Intl.PluralRules. The count is automatically merged into data so {{count}} works without restating it.
A plural leaf is a dedicated plural node — built with the definePlural helper in TS / JS, or written as a { "type": "plural", "data": { ... } } literal in JSON. A plural node is the only thing interpreted as a plural; a plain object inside defineTranslations is always treated as a nested key.
TS / JS files (inline defineCatalog, or loaded by FSStore) — use the definePlural helper:
import {
Ilingo,
MemoryStore,
defineCatalog,
defineLocale,
defineNamespace,
defineTranslations,
definePlural,
} from 'ilingo';
const catalog = defineCatalog([
defineLocale('en', [
defineNamespace('cart', [
defineTranslations({
items: definePlural({
one: '{{count}} item',
other: '{{count}} items',
}),
}),
]),
]),
]);
const ilingo = new Ilingo({
store: new MemoryStore({ data: catalog }),
});
await ilingo.get({ namespace: 'cart', key: 'items', count: 1 }); // "1 item"
await ilingo.get({ namespace: 'cart', key: 'items', count: 5 }); // "5 items"JSON files (loaded by FSStore) — use the literal plural node:
{
"type": "translations",
"data": {
"items": {
"type": "plural",
"data": {
"one": "{{count}} item",
"other": "{{count}} items"
}
}
}
}definePlural returns a plural node with the same runtime shape as the JSON { "type": "plural", "data": { ... } } literal. The TS/JS version gives local CLDR-category autocomplete and a compile error if you misspell other or pass a non-CLDR key.
If the selected category is absent from the leaf, other is used as a fallback.
Plural leaves round-trip through store.set() — StoreSetContext.value accepts either a string or a plural node (definePlural(...) / { type: 'plural', data }), not the unwrapped PluralForms, so the store can recognise and unwrap it on read. The FSStore.set persistence writes them as JSON unchanged.
Fallback locale chain
get() walks an ordered fallback chain. By default the chain is derived from BCP-47 parents of the requested locale, terminating at en:
new Ilingo({ locale: 'pt-BR' }).getResolvedLocaleChain({ locale: 'pt-BR' });
// ['pt-BR', 'pt', 'en']Override with fallback:
new Ilingo({ fallback: 'es' }); // string
new Ilingo({ fallback: ['es', 'fr'] }); // array, in order
new Ilingo({ // function: per-call
fallback: (locale) => locale.startsWith('pt') ? ['es'] : [],
});
new Ilingo({ fallback: false }); // disable fallback entirely
new Ilingo({ fallback: [] }); // equivalent to `false`The chain is walked locale-first across all stores — the closest locale match wins regardless of store order. Within a single locale, stores are queried serially in insertion order, stopping at the first hit. A network-backed adapter registered after a Memory adapter is never called when the Memory adapter answers — the orchestrator does not pre-fan-out across stores.
Inspect the resolution with:
ilingo.getResolvedLocaleChain({ locale: 'pt-BR' });
// ['pt-BR', 'pt', 'en']
await ilingo.getResolvedLocale({ namespace: 'app', key: 'hi' });
// 'pt' — which locale actually yielded a value
// undefined if no store had the key anywhere in the chainMissing-key handler
Override the default dev-mode console.warn via onMissingKey. Return a string to make it the result of get(); return undefined to keep the result undefined.
const ilingo = new Ilingo({
onMissingKey: ({ namespace, key, resolvedLocale }) => {
track('i18n.miss', { namespace, key, locale: resolvedLocale });
return `[missing: ${namespace}.${key}]`;
},
});Formatters
Template placeholders support modifiers backed by Intl.NumberFormat, Intl.DateTimeFormat, and Intl.ListFormat:
const ilingo = new Ilingo({
store: new MemoryStore({
data: defineCatalog([
defineLocale('en', [
defineNamespace('app', [
defineTranslations({
owe: 'You owe {{amount, number(style=currency, currency=EUR)}}',
signed: 'Signed {{date, date(dateStyle=medium, timeZone=UTC)}}',
invited: '{{people, list(style=long, type=conjunction)}}',
}),
]),
]),
]),
}),
});
await ilingo.get({ namespace: 'app', key: 'owe', data: { amount: 99 } }); // "You owe €99.00"
await ilingo.get({ namespace: 'app', key: 'signed', data: { date: '2026-05-22T12:00:00Z' } }); // "Signed May 22, 2026"
await ilingo.get({ namespace: 'app', key: 'invited', data: { people: ['Alice', 'Bob', 'Carol'] } });
// → "Alice, Bob, and Carol"Syntax:
{{value}} plain substitution
{{value, formatter}} formatter with no options
{{value, formatter(k=v, k2=v2)}} formatter with optionsThe locale used to construct the Intl.*Format instance is the resolved locale — the one that actually yielded the message via the fallback chain — not the requested one. Intl.*Format instances are memoised per (formatter, locale, options) on the Ilingo instance, so repeated renders do not reallocate.
Option-value coercion: 42 → 42 (number), true / false → boolean, anything else → string. So currency=EUR becomes { currency: 'EUR' }, minimumFractionDigits=2 becomes { minimumFractionDigits: 2 }.
Unknown modifiers fall back to String(value) and emit a one-shot dev-mode warning (silenced in process.env.NODE_ENV === 'production'). Malformed modifier expressions (unbalanced parens, non-identifier names) are treated the same way — never throw.
Authoring catalogs
A catalog is a tree of tagged descriptor nodes built with four helpers:
defineCatalog(locales)— the root; the value you pass tonew MemoryStore({ data }). Its children aredefineLocale(...)nodes.defineLocale(name, children)— one locale ('en','de', …). Its children aredefineNamespace(...)(and/ordefineTranslations(...)) nodes.defineNamespace(name, children)— a namespace. Its children are nesteddefineNamespace(...)and/ordefineTranslations(...)nodes.defineTranslations(obj)— a flat or key-nested map of translation strings (anddefinePlural(...)leaves).
import {
Ilingo,
MemoryStore,
defineCatalog,
defineLocale,
defineNamespace,
defineTranslations,
definePlural,
} from 'ilingo';
const catalog = defineCatalog([
defineLocale('en', [
defineNamespace('app', [
defineTranslations({ greeting: 'Hi {{name}}', nav: { home: 'Home' } }), // nav.home is a dotted KEY
]),
defineNamespace('cart', [
defineTranslations({ items: definePlural({ one: '{{count}} item', other: '{{count}} items' }) }),
]),
]),
]);
const ilingo = new Ilingo({ store: new MemoryStore({ data: catalog }) });
await ilingo.get({ namespace: 'app', key: 'greeting', data: { name: 'Peter' } }); // "Hi Peter"
await ilingo.get({ namespace: 'app', key: 'nav.home' }); // "Home"
await ilingo.get({ namespace: 'cart', key: 'items', count: 5 }); // "5 items"There are two independent nesting hierarchies:
- Nested
defineNamespacebuilds a dotted namespace.defineNamespace('app', [defineNamespace('nav', …)])exposes the inner namespace as'app.nav'. - A nested object inside
defineTranslationsbuilds a dotted key.defineTranslations({ nav: { home: 'Home' } })exposes the leaf as key'nav.home'.
get() keys are loose strings and get() returns Promise<string | undefined>. The store model is open-world — API- and loader-backed stores hold keys that aren't known at build time — so there is no catalog-driven key inference. definePlural still gives you local CLDR-category autocomplete and a compile error on a missing other (or a non-CLDR key), but it does not drive get()'s key types.
defineCatalog and friends are runtime functions that normalize the tree into the internal Locales shape (normalizeCatalog / normalizeNamespaceBody are exported for advanced use). Node types (CatalogNode, LocaleNode, NamespaceNode, TranslationsNode, PluralNode, NamespaceChild, CatalogInput, NamespaceBodyInput) and guards (isCatalogNode, isLocaleNode, isNamespaceNode, isTranslationsNode, isPluralNode) are exported too.
The IIlingo interface
IIlingo is the public type contract of the orchestrator — every method on the concrete Ilingo class plus the stores map and formatters registry. Library code that accepts an orchestrator (@ilingo/vue, @ilingo/vuelidate, @ilingo/validup, …) accepts and returns IIlingo, so consumers can swap in test doubles or decorating wrappers without depending on the concrete class.
import type { IIlingo } from 'ilingo';
function register(ilingo: IIlingo) {
ilingo.registerStore(myStore); // myStore.id = Symbol.for('@scope/pkg')
}new Ilingo() is still the way to construct an instance. Prefer IIlingo as the type position; reserve Ilingo (the class) for construction and instanceof checks.
Slot placeholders & tokenize()
In addition to {{var}} data placeholders (and modifier syntax), messages can carry {slot} markers (single curly braces) for renderers that produce structured output rather than a string. The core tokenize(str) helper parses a message into text / var / slot tokens:
import { tokenize } from 'ilingo';
tokenize('Hi {{user}}, please {cta} now.');
// [
// { kind: 'text', value: 'Hi ' },
// { kind: 'var', name: 'user' },
// { kind: 'text', value: ', please ' },
// { kind: 'slot', name: 'cta' },
// { kind: 'text', value: ' now.' },
// ]tokenize() and template() are parallel parsers — template() returns a substituted string (used by Ilingo.format); tokenize() returns tokens for VNode-producing renderers (e.g. @ilingo/vue's <ITranslateT>). Plain Ilingo.get() always returns a string, so {slot} markers survive into the output unless a slot-aware renderer consumes them.
Custom formatters
Register your own modifier names alongside the built-in number / date / list:
const ilingo = new Ilingo({
store: /* ... */,
formatters: {
upper: (value, _opts, locale) => String(value).toLocaleUpperCase(locale),
relative: (value, _opts, locale) => {
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
return rtf.format(Number(value), 'day');
},
},
});
await ilingo.get({
namespace: 'app', key: 'shout',
data: { name: 'peter' },
});
// "{{name, upper}}" → "PETER"Or call ilingo.registerFormatter(name, fn) after construction. Custom formatters receive (value, options, locale) — options is the parsed {key=value, ...} from inside the modifier parens.
IlingoOptions.formatters overrides win against the built-ins by name, so you can swap the default number formatter for a custom one if needed.
Locale negotiation
negotiateLocale(supported, requested) picks the best match between a list of supported locales and a list of requested ones (typically from an HTTP Accept-Language header). Implements BCP-47 best-match: exact match → prefix match → parent walk.
import { Ilingo, MemoryStore, negotiateLocale, parseAcceptLanguage } from 'ilingo';
const ilingo = new Ilingo({
store: new MemoryStore({ data: /* ... */ }),
});
const supported = ['en', 'de', 'pt-BR'];
negotiateLocale(supported, ['pt-PT', 'pt', 'en']);
// → 'pt-BR' (requested 'pt' matches supported 'pt-BR' as a parent prefix)
const fromHeader = parseAcceptLanguage('en-US,en;q=0.9,de;q=0.8');
// → ['en-US', 'en', 'de']
const chosen = negotiateLocale(supported, fromHeader) ?? 'en';
ilingo.setLocale(chosen);parseAcceptLanguage(header) parses the RFC 9110 header into a quality-sorted tag list. Both functions are pure utilities — they don't mutate Ilingo state. Compose them with setLocale() to wire request-side locale negotiation in a server (Express / Hono / etc.) or client (navigator.languages).
Store
A store implements the read IStore port — id, get, getLocales. ilingo is read-first: the orchestrator only ever reads (it never calls set), so that's the whole required contract, and it is frozen for the stable release. Writing is an opt-in capability — IMutableStore adds set(ctx) and is implemented by MemoryStore (in-memory) and FSStore (disk); extendStore(...) takes a IMutableStore, and isMutableStore(store) is the runtime guard. Other capabilities (cache invalidation, file watching, …) layer the same way (see Invalidation below). has, delete, getKeys, and batch getAll were each considered and deferred — see the JSDoc on IStore in packages/ilingo/src/store/types.ts for the per-method rationale.
Registering stores — registerStore(store)
Ilingo holds its stores in a public readonly stores: Map<symbol | string, IStore>, keyed by each store's own id identity, queried serially in insertion order (first hit wins). Add stores with registerStore:
const ilingo = new Ilingo({ store: appStore }); // constructor seeds the first store
ilingo.registerStore(overrideStore); // anonymous Symbol() id → always added
ilingo.registerStore(libraryStore); // libraryStore.id = Symbol.for('@me/lib') → idempotent- Anonymous
id(a freshSymbol(), theMemoryStoredefault) — the store is always added, since eachSymbol()is unique. - Stable
id(aSymbol.for('@scope/pkg')set on the store) — idempotent: a no-op (keeping the existing store) if a store with thatidis already registered, so re-registration — even from a duplicate package copy — never stacks duplicates. This is how@ilingo/validupand@ilingo/vuelidateregister their catalogs: each ships a catalog store keyed by its exportedSTORE_ID, added withilingo.registerStore(createMemoryStore()).
Because a namespace is a shared key-space (the walk falls through store-by-store per missing key), registering an app store before a library's catalog lets the app add or override individual keys of that namespace while the library supplies the defaults. Ilingo implements the IIlingo interface — type against IIlingo when you want to accept any orchestrator implementation.
Memory Store
The Memory Store is the default store and is set if no other Store is specified manually.
Loader Store
For browser / SPA apps with code-split locale chunks, LoaderStore lazy-loads translation data via a user-supplied function and caches the result per (locale, namespace):
import { Ilingo, LoaderStore } from 'ilingo';
const ilingo = new Ilingo({
store: new LoaderStore({
loader: async (locale, namespace) => {
// The module default is a translations node (`{ "type": "translations", "data": { ... } }`,
// or `export default defineTranslations({ ... })` for TS/JS chunks).
const m = await import(`./locales/${locale}/${namespace}.json`);
return m.default;
},
locales: ['en', 'de', 'fr'], // optional — answers `getLocales()`
}),
});
await ilingo.get({ namespace: 'cart', key: 'items', count: 3 });
// First call loads `./locales/en/cart.json`; subsequent calls hit the cache.Concurrent get()s for the same (locale, namespace) share one loader invocation. Misses (loader returning undefined) are cached too, so the loader isn't re-called for keys it has no answer for.
Invalidation
Stores that cache lookups can implement IInvalidatingStore:
export interface IInvalidatingStore extends IStore {
invalidate(locale?: string, namespace?: string): void;
on(event: 'invalidate', listener: (locale?: string, namespace?: string) => void): () => void;
}Drop scoped cache entries with invalidate(locale?, namespace?) — () drops everything, ('en') drops all namespaces for en, ('en', 'app') drops just one namespace. Subscribe to invalidation events via on('invalidate', cb) to react to file changes or manual drops.
Both LoaderStore and FSStore implement this interface. The Vue composable (@ilingo/vue) subscribes automatically — file changes under FSStore({ watch: true }) trigger a re-render without a remount.
Detect via the isInvalidatingStore(store) type guard before subscribing.
FS Store
The FSStore is a Store which access the FileSystem for locating namespace files of different locales.
License
Made with 💚
Published under MIT License.
