Skip to content

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.

typescript
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:

typescript
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:

typescript
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 guard

MemoryStore (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):

typescript
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:

typescript
// 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:

AsyncSyncMeaning
resolves Leafreturns Leafhit
resolves undefinedreturns undefineddefinite miss — the key isn't here
rejectsthrows SyncUnavailableErrorno 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.

typescript
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:

StoreBehaviour
MemoryStorealways — undefined is always a definite miss
LoaderStorefor (locale, namespace) pairs already loaded; throws otherwise (it does not kick off the loader)
FSStorealways — 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):

typescript
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:

typescript
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.

typescript
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:

typescript
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:

typescript
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 fresh Symbol(), the MemoryStore default) — always added, since each Symbol() is unique.
  • Stable id (Symbol.for('@scope/pkg') set on the store) — idempotent. If a store with that id is 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 Ilingo instance — 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() returns undefined per 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.
typescript
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.

typescript
const base = new Ilingo({ store: storeA });
const themed = new Ilingo({ store: storeB });

base.merge(themed); // base now queries storeA then storeB

Writing 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 / getLocalesno set stub:

typescript
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 (use Symbol.for('@scope/name') for a library catalog so it dedupes across duplicate copies); Ilingo.registerStore keys the store map by it.
  • Return undefined on 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 inner PluralForms before returning, matching MemoryStore and LoaderStore.
  • If you need a load cache, extending MemoryStore and using the parent map is idiomatic (FSStore does this).

Released under the MIT License.