Stores
A store is anything that can return a translation leaf for a given (locale, namespace, key). ilingo is read-first — its job is to read a datasource — so the IStore port is the read contract: id, get, getSync, getLocales.
import type { Leaf, PluralNode } from 'ilingo';
export type StoreGetContext = { locale: string, namespace: string, key: string };
export type StoreSetContext = StoreGetContext & { value: string | PluralNode };
export interface IStore {
readonly id: string | symbol;
get(context: StoreGetContext): Promise<Leaf | undefined>;
getSync(context: StoreGetContext): Leaf | undefined; // throws SyncUnavailableError
getLocales(): Promise<string[]>;
}The read comes in both idioms, as it does in the sibling packages (confinity's IStore, locter's IReader). An adapter that genuinely cannot read without I/O still implements getSync — by declining:
import { throwSyncUnavailable } from 'ilingo';
getSync(context: StoreGetContext): Leaf | undefined {
throwSyncUnavailable(context, this.id);
}The orchestrator only ever calls get (and getLocales) — it never writes. So a read-only adapter (a remote/HTTP datasource) implements just these. Writing is an opt-in capability, IMutableStore:
import type { IStore, StoreSetContext } from 'ilingo';
export interface IMutableStore extends IStore {
set(context: StoreSetContext): Promise<void>;
}
export function isMutableStore(store: IStore): store is IMutableStore; // type guardMemoryStore (in-memory mutation) and FSStore (writes through to disk) implement it; extendStore(...) takes a IMutableStore. All methods are async — keep that contract even when the implementation is synchronous, because Ilingo.lookup awaits every store call.
Required surface
The IStore read port is id / get / getSync / getLocales. Capabilities beyond reading layer as separate interfaces detected via type guards — IMutableStore (writing) and IInvalidatingStore (caching, below) are the pattern. has, delete, getKeys, and batch getAll were considered and deferred (see the source JSDoc for the rationale per method); they would follow the same opt-in-interface pattern if added later.
getSync is required rather than an opt-in capability: keeping it optional meant two different ways to say "I can't answer synchronously" — no method at all, or a method that declines — and every caller had to handle both. See Synchronous reads.
Adding it to an existing custom store: the three-line getSync above is enough. Until you do, Ilingo.getSync() throws a SyncUnavailableError naming your store and what to implement, and get() keeps working untouched — so nothing else in your app changes.
MemoryStore
The default. Holds the catalog tree built with the define* helpers (see Catalog Design):
import { Ilingo, MemoryStore, defineCatalog, defineLocale, defineNamespace, defineTranslations } from 'ilingo';
const store = new MemoryStore({
data: defineCatalog([
defineLocale('en', [defineNamespace('app', [defineTranslations({ hi: 'Hello, {{name}}!' })])]),
defineLocale('de', [defineNamespace('app', [defineTranslations({ hi: 'Hallo, {{name}}!' })])]),
]),
});
const ilingo = new Ilingo({ store });You can also write at runtime — useful when translations come from an API. set() satisfies the async IMutableStore port, but MemoryStore is in-memory, so it also exposes a synchronous setSync() for seeding data after construction without an await:
// synchronous — no await needed (MemoryStore-specific)
store.setSync({ locale: 'es', namespace: 'app', key: 'hi', value: '¡Hola, {{name}}!' });
// the async port method — same effect; delegates to setSync()
await store.set({ locale: 'es', namespace: 'app', key: 'hi', value: '¡Hola, {{name}}!' });setSync and getLocalesSync are concrete MemoryStore methods, not part of the async IStore / IMutableStore port — an async-only backend (a remote datasource) can't answer synchronously, so the port stays async and only stores that genuinely hold data in memory offer the sync variants. Synchronous reading is different: it is part of the port — getSync — because the orchestrator itself uses it.
Synchronous reads (getSync)
Ilingo.get() returns a Promise, which is a problem for server-side rendering: a Vue computedAsync renders its placeholder first, that placeholder lands in the HTML, and the client's first render then mismatches it. Ilingo.getSync() resolves the same lookup without awaiting, so the first render is the real string on both sides of the hydration boundary (issue #988).
A synchronous read has exactly two outcomes, mirroring the asynchronous one:
| Async | Sync | Meaning |
|---|---|---|
resolves Leaf | returns Leaf | hit |
resolves undefined | returns undefined | definite miss — the key isn't here |
| rejects | throws SyncUnavailableError | no answer: a store would need I/O |
That symmetry is the whole design. A sentinel return value would invent a third outcome the async side doesn't have, and would share a channel with undefined — but callers must tell "no such key" from "ask me later", because the two want opposite fallbacks. @ilingo/validup is the clearest case: a missing key falls back to issue.message, while a cold store must not, since the async pass is about to produce a real translation.
import { isSyncUnavailableError } from 'ilingo';
try {
const value = ilingo.getSync({ namespace: 'app', key: 'hi', data: { name: 'Peter' } });
// undefined here means the key really is missing
} catch (e) {
if (!isSyncUnavailableError(e)) throw e;
// a store needs I/O — fall back to the authoritative path
const value = await ilingo.get({ namespace: 'app', key: 'hi', data: { name: 'Peter' } });
}SyncUnavailableError extends IlingoError → @ebec/core's BaseError, the same base locter and validup use. It carries a code (SYNC_UNAVAILABLE_ERROR), an optional cause, the locale / namespace / key / storeId of the declined lookup, and a toJSON() that preserves them. Use isSyncUnavailableError rather than a bare instanceof: it is Symbol.for-marker based, so it holds across a duplicate copy of ilingo (thrown inside @ilingo/fs, caught in your app) and matches an error rehydrated from toJSON(). isIlingoError is the same guard one level up, matching anything ilingo threw.
Whenever getSync() returns, the result is strictly equivalent to await get(): same locale chain, same store order, same plural selection and interpolation, same onMissingKey routing. The walk aborts at the first store that declines rather than skipping it — skipping would let a cold store holding the de string be passed over in favour of a warm store's en fallback, i.e. a silently wrong translation.
Who can answer synchronously:
| Store | Behaviour |
|---|---|
MemoryStore | always — undefined is always a definite miss |
LoaderStore | for (locale, namespace) pairs already loaded; throws otherwise (it does not kick off the loader) |
FSStore | always — reads the file off disk synchronously, cold or warm; throws only for a module that needs an asynchronous import() |
| a custom async-only store (HTTP, DB) | throws via throwSyncUnavailable(context, this.id) |
@ilingo/vue uses this automatically — see SSR & hydration.
LoaderStore
For browser / SPA apps that code-split locales, 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({
// the module's default export is a translations node (e.g. defineTranslations({ ... })
// or a JSON `{ "type": "translations", "data": { … } }`)
loader: async (locale, namespace) => {
const m = await import(`./locales/${locale}/${namespace}.json`);
return m.default;
},
locales: ['en', 'de', 'fr'],
}),
});- Concurrent
get()s for the same(locale, namespace)share one loader call. - Misses (loader returning
undefined) are cached too — the loader isn't re-called for known-missing pairs. - Implements
IInvalidatingStore— see Cache invalidation.
Cache 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;
}Both LoaderStore and FSStore implement it. @ilingo/vue's useTranslation automatically subscribes to every IInvalidatingStore in the instance's store set — so file changes under FSStore({ watch: true }) show up in the rendered UI without a remount.
import { isInvalidatingStore } from 'ilingo';
for (const store of ilingo.stores.values()) {
if (isInvalidatingStore(store)) {
store.on('invalidate', (locale, namespace) => {
console.log(`reloaded ${locale ?? '*'}/${namespace ?? '*'}`);
});
}
}FSStore
Lazy-loads files from disk. See Integrations → File System for the full story; the gist:
import { Ilingo } from 'ilingo';
import { FSStore } from '@ilingo/fs';
const ilingo = new Ilingo({
store: new FSStore({ directory: './locales' }),
});
// reads ./locales/en/app.json (or .ts / .mjs / .cjs / .conf) on first access
await ilingo.get({ namespace: 'app', key: 'hi' });Multiple stores
An Ilingo instance exposes public readonly stores: Map<symbol | string, IStore> — each store's own id is its key, and the Map's insertion order is the query order. Register as many as you want via registerStore(store); they are queried serially in insertion order within each locale, stopping at the first hit:
const ilingo = new Ilingo({
store: new MemoryStore({ data: defineCatalog([/* core strings */]) }),
});
// add more after construction — checked only when the earlier store misses
ilingo.registerStore(new FSStore({ directory: './locales/overrides' }));registerStore(store) keys the store by its own id (string | symbol):
- Anonymous
id(a freshSymbol(), theMemoryStoredefault) — always added, since eachSymbol()is unique. - Stable
id(Symbol.for('@scope/pkg')set on the store) — idempotent. If a store with thatidis already registered, the call is a no-op and the existing store is kept, so registering twice (or across a duplicate package copy) never stacks duplicates.
The constructor's store option is just registerStore(store) under the hood. The serial walk is the reason "local first, remote fallback" compositions work as written: a network-backed store registered after a Memory store is only consulted when the Memory store has nothing for (locale, namespace, key) — the orchestrator does not pre-fan-out across stores. Locale-first composition still applies: a closer locale always beats a farther one regardless of which store would have answered.
Composing many sources — namespace is a shared key-space
A real app pulls translations from several sources: the app's own catalog, plus library catalogs like @ilingo/validup (validation messages) or @ilingo/vuelidate. The model is one instance, many stores, namespace as a shared key-space:
- Each source is its own store on the same
Ilingoinstance — so all sources share one set of formatters, one fallback chain, one missing-key handler. - A
namespace(app,email,validup, …) is not owned by a single store.MemoryStore.get()returnsundefinedper missing key, so the orchestrator falls through store-by-store within the same namespace. That means an app can co-own a library's namespace — add its own keys, override individual ones — just by registering its own store first.
import { Ilingo } from 'ilingo';
import { FSStore } from '@ilingo/fs';
import { createMemoryStore } from '@ilingo/validup/store/memory';
const ilingo = new Ilingo({ fallback: ['en'] });
// app catalog FIRST → its keys win per (locale, namespace, key)
ilingo.registerStore(new FSStore({ directory: './locales' }));
// library catalog appended → fills the built-in defaults the app store misses
ilingo.registerStore(createMemoryStore());Now a lookup for (en, validup, value_invalid) falls through the app store (no such key) to the validup catalog, while (en, validup, my_custom_code) — a code the app defined under the validup namespace, e.g. via a ./locales/en/validup.json file (FSStore derives the namespace from the filename) — is answered by the app store. Overriding a single built-in message works the same way: define (en, validup, value_invalid) in the app store and it wins, with every other code still served by the library.
Merging instances
merge(other) folds another instance's stores in, deduping by symbol identity: a foreign store whose key is already present is skipped (the existing one wins); foreign keys not present are appended in order. Library catalogs keyed by Symbol.for('@scope/pkg') never stack across a merge; anonymously-keyed stores are always distinct and so always carried over.
const base = new Ilingo({ store: storeA });
const themed = new Ilingo({ store: storeB });
base.merge(themed); // base now queries storeA then storeBWriting a custom store
Implement the interface as a class — not an object literal — so signature drift is caught at compile time. Because the IStore port is read-only, a remote datasource implements just id / get / getLocales — no set stub:
import type { IStore, StoreGetContext, Leaf } from 'ilingo';
export class HttpStore implements IStore {
readonly id = Symbol.for('app/http-store');
async get(ctx: StoreGetContext): Promise<Leaf | undefined> {
const res = await fetch(`/i18n/${ctx.locale}/${ctx.namespace}.json`);
if (!res.ok) return undefined;
const data = await res.json();
return data[ctx.key];
}
async getLocales(): Promise<string[]> {
return ['en', 'de'];
}
}If your store is writable, implement IMutableStore instead (add set(ctx: StoreSetContext)); isMutableStore(store) lets callers detect it.
Rules of thumb:
- Give every store a stable
id(useSymbol.for('@scope/name')for a library catalog so it dedupes across duplicate copies);Ilingo.registerStorekeys the store map by it. - Return
undefinedon miss. Never throw. Throwing breaks the fallback walk. - Returning
PluralForms(the unwrapped CLDR-categorised options) is allowed but optional. String-only stores are valid. Custom stores that hold a raw plural node ({ type: 'plural', data: { … } }) should unwrap to the innerPluralFormsbefore returning, matchingMemoryStoreandLoaderStore. - If you need a load cache, extending
MemoryStoreand using the parent map is idiomatic (FSStoredoes this).