@xndrjs/i18n
v0.6.0
Published
Compiler-first, type-safe ICU MessageFormat i18n with runtime dictionary override from external sources.
Readme
@xndrjs/i18n
A compiler-first, type-safe i18n system based on the ICU MessageFormat standard, with runtime dictionary overrides from external sources (no rebuild required).
The core idea: your ICU strings live in local JSON files that act as type-safe fallbacks. A build-time codegen step parses the ICU AST and generates exact TypeScript types for every translation key and its parameters. At runtime, a centralized provider caches compiled messages and lets you replace the whole dictionary (or a single namespace) on the fly from any source (i.e. a CMS).
Key features
- Type-safe
.get()— the compiler knows exactly which parameters each key requires (string,number, or none). - ICU MessageFormat — full support for interpolation, plurals, and select.
- Runtime override — hydrate translations from an external source via
setAll()/setNamespace()without rebuilding. - Single-file or multi-namespace — one flat dictionary, or multiple JSON files each bound to a namespace.
- Lazy namespace loading — optional code-splitting via
loadOnInitand generatednamespaceLoaders(multi mode). - Hot compilation cache — compiled
IntlMessageFormatinstances are cached and invalidated on override. - Explicit runtime errors — malformed ICU (e.g. a corrupt remote payload) or missing parameters throw descriptive errors.
- Translation audit —
xndrjs-i18n-auditreports missing locales per key (direct vs effective after fallback); optional CI gate via--fail-on. - Publishable library — the runtime and codegen live in a standalone package (
@xndrjs/i18n) that carries no project-specific types.
Getting started
Install
npm install @xndrjs/i18n zod
npm install -D tsxtsx and zod are peer dependencies of @xndrjs/i18n:
tsx— required byxndrjs-i18n-codegenandxndrjs-i18n-audit(CLIs run TypeScript directly). Install as a devDependency — only needed at build/CI time.zod— required by codegen/audit config validation (i18n.codegen.json) and by@xndrjs/i18n/validationwhen usingdictionarySchemaOutputandvalidateExternalDictionary(). Use a devDependency if validation runs only at build time; a regular dependency if you validate CMS payloads in production.
Quick setup (single file)
1. Translation JSON (i18n/translations/translations.json):
{
"login_button": { "en": "Login", "it": "Accedi" },
"welcome": { "en": "Welcome {name}!" }
}2. Codegen config (i18n/i18n.codegen.json):
{
"dictionary": "translations/translations.json",
"typesOutput": "generated/i18n-types.generated.ts",
"dictionaryOutput": "generated/dictionary.generated.ts",
"instanceOutput": "generated/instance.generated.ts",
"paramsTypeName": "MyProjectParams",
"schemaTypeName": "MyProjectSchema"
}3. npm script (package.json):
{
"scripts": {
"i18n:codegen": "xndrjs-i18n-codegen --config i18n/i18n.codegen.json",
"i18n:audit": "xndrjs-i18n-audit --config i18n/i18n.codegen.json"
}
}4. Generate and audit:
npm run i18n:codegen
npm run i18n:audit// i18n/index.ts
import { createI18n } from "./generated/instance.generated.js";
import { defaultDictionary } from "./generated/dictionary.generated.js";
export * from "./generated/instance.generated.js";
export * from "./generated/dictionary.generated.js";
export * from "./generated/i18n-types.generated.js";
export const i18n = createI18n(defaultDictionary);import { i18n } from "./i18n";
i18n.get("login_button", "it"); // "Accedi"
i18n.get("welcome", "en", { name: "Ada" }); // "Welcome Ada!"Run codegen after every change to your JSON files (or wire it into your build).
Or scaffold the starter files with the setup CLI:
xndrjs-i18n-setup single . --project MyApp
xndrjs-i18n-setup multi apps/myapp --project MyAppThis creates i18n/i18n.codegen.json, starter translation JSON, and i18n/index.ts under the target directory. Pass src as the target directory if your project uses a src/ layout (src/i18n/). Edit the config for lazy loading, validation, locale fallback, and extra namespaces, then run codegen.
Quick setup (multi namespace)
Use namespaces instead of dictionary in i18n/i18n.codegen.json. See Configuration and the multi-namespace example below.
For lazy loading, add loadOnInit and namespaceLoadersOutput — see Lazy namespace loading. Add dictionarySchemaOutput only when you validate external CMS/API payloads.
Repository layout
This package lives in the xndrjs-toolkit pnpm monorepo.
xndrjs-toolkit/
├── packages/
│ └── i18n/ # @xndrjs/i18n — the publishable library
│ ├── bin/
│ │ ├── codegen.mjs # CLI entry: xndrjs-i18n-codegen
│ │ └── audit.mjs # CLI entry: xndrjs-i18n-audit
│ └── src/
│ ├── index.ts # public exports
│ ├── types.ts # generic dictionary/cache types
│ ├── IcuTranslationProviderSingle.ts
│ ├── IcuTranslationProviderMulti.ts
│ └── codegen/
│ └── generate-i18n-types.ts
└── apps/
└── i18n-demo/ # @xndrjs/i18n-demo — workshop app
├── single/ # single-file example (config at sub-project root)
│ ├── i18n.codegen.json
│ └── src/i18n/
└── multi/ # multi-namespace example
├── i18n.codegen.json
└── src/i18n/A typical consumer app scaffolded with xndrjs-i18n-setup single . colocates everything under i18n/:
my-app/
└── i18n/
├── i18n.codegen.json
├── index.ts
├── generated/
└── translations/For a src/ layout, run xndrjs-i18n-setup single src instead:
my-app/
└── src/
└── i18n/
├── i18n.codegen.json
├── index.ts
├── generated/
└── translations/Library vs. consumer
@xndrjs/i18n is generic and never imports project-specific types. It exposes two provider classes parametrized by Schema and Params generics, plus the codegen CLI.
The consumer app owns its ICU JSON, its codegen config, and the generated files that bind the generic providers to concrete types.
1. Source JSON
Each translation key maps locale codes to ICU strings. Structure is identical in both modes: key -> locale -> ICU string.
{
"login_button": { "it": "Accedi", "en": "Login" },
"welcome": { "it": "Benvenuto {name}!", "en": "Welcome {name}!" },
"dashboard_status": {
"it": "Hai {msgCount, plural, one {1 messaggio} other {{msgCount} messaggi}} in {chatCount, plural, one {una chat} other {{chatCount} chat}}",
"en": "You have {msgCount, plural, one {1 message} other {{msgCount} messages}} in {chatCount, plural, one {one chat} other {{chatCount} chats}}"
}
}2. Codegen (build-time)
xndrjs-i18n-codegen reads i18n.codegen.json (by default i18n/i18n.codegen.json), parses every ICU string with @formatjs/icu-messageformat-parser, and infers parameter types:
| ICU construct | Inferred type |
| ----------------------------------------- | ------------- |
| Simple argument {name} | string |
| plural argument {count, plural, ...} | number |
| select argument {gender, select, ...} | string |
| No variables | never |
Variables found across all locales of the same key are merged. If parsing fails for any key/locale, codegen prints a contextual error and exits with a non-zero code (so it blocks CI/builds).
3. Generated files
i18n-types.generated.ts—I18N_MODE,MyProjectParams,MyProjectSchema. Withdelivery: "custom", alsoMyProjectDeliveryArea,DELIVERY_ARTIFACTS,LOCALE_DELIVERY_AREA, andMyProjectDeliveryArtifacts.dictionary.generated.ts— imports the JSON files and exportsdefaultDictionary(canonical delivery) ordefaultDictionaryFor(locale)/defaultDictionaryFor(area)when eager namespaces exist in split/custom delivery. Omitted when every namespace is lazy in split/custom delivery.instance.generated.ts— exportscreateI18n(dictionary)(required argument; no default import of the fallback dictionary), typedprojectDictionaryLocales()(full schema), and in multi modeprojectNamespaceLocales()(single namespace); withdelivery: "custom", alsoprojectDictionaryForDeliveryArea()andprojectNamespaceForDeliveryArea();LOCALE_FALLBACKwired in when configured.i18n.ts(optional, hand-written) — app-owned singleton if desired.
Example generated types (multi-namespace):
export const I18N_MODE = "multi" as const;
export type MyProjectParams = {
default: {
login_button: never;
welcome: { name: string };
dashboard_status: { msgCount: number; chatCount: number };
};
user: {
profile_title: never;
greeting: { name: string };
};
billing: {
invoice_summary: { count: number };
};
};
export type MyProjectSchema = {
default: {
login_button: Partial<Record<MyProjectLocale, string>>;
welcome: Partial<Record<MyProjectLocale, string>>;
dashboard_status: Partial<Record<MyProjectLocale, string>>;
};
user: {
profile_title: Partial<Record<MyProjectLocale, string>>;
greeting: Partial<Record<MyProjectLocale, string>>;
};
billing: {
invoice_summary: Partial<Record<MyProjectLocale, string>>;
};
};4. Runtime provider
Create a provider with the generated factory (no side effects at import time). Pass the dictionary explicitly — from codegen’s dictionary.generated.ts, from lazy loaders, or from an external source — so the factory module does not pull fallback JSON into your bundle:
import { createI18n } from "./i18n";
import { defaultDictionary } from "./i18n/generated/dictionary.generated.js";
const i18n = createI18n(defaultDictionary);
// or with an external dictionary at init:
const i18n = createI18n(externalDictionary);Optionally, wrap it in an app-owned singleton (i18n.ts):
import { createI18n } from "./instance.generated.js";
import { defaultDictionary } from "./dictionary.generated.js";
export const i18n = createI18n(defaultDictionary);Locale-bound provider (forLocale)
Bind a locale once and omit it on every .get():
const i18n = createI18n(defaultDictionary);
const i18nEn = i18n.forLocale("en");
i18nEn.get("login_button"); // single-file
i18nEn.get("welcome", { name: "Ada" });
const i18nIt = i18n.forLocale("it");
i18nIt.get("default", "login_button"); // multi-namespace
i18nIt.get("billing", "invoice_summary", { count: 3 });The bound view shares the parent dictionary, cache, and fallback rules. It exposes locale and a narrower get() signature only.
Because Params[K] (or Params[NS][K]) is used in a conditional rest parameter, TypeScript enforces the exact argument shape:
...params: Params[NS][K] extends never ? [] : [params: Params[NS][K]]5. Locale fallback
When a translation is missing for the requested locale (undefined in the dictionary), the provider can walk a fallback chain before throwing. An empty string "" is treated as a valid template and does not trigger fallback.
"localeFallback": {
"en": null,
"de-DE": "en",
"de-CH": "de-DE",
"it": "en"
}nullmarks a terminal locale (no further fallback).- Any other value is the next locale to try, recursively.
- If the chain ends without finding a template,
.get()throws and includes the full chain in the error message.
Codegen emits LOCALE_FALLBACK and extends MyProjectLocale with the fallback locales. The generated factory wires the map into the provider automatically.
When localeFallback is present in config, codegen enriches the generated map: every locale in MyProjectLocale that is missing from your config is added with null (explicit terminal — no fallback chain). Runtime behavior is unchanged; the map is simply complete. If localeFallback is omitted from config, no LOCALE_FALLBACK is emitted (current behavior).
You can also pass a fallback map manually when constructing a provider:
import { IcuTranslationProviderMulti, type LocaleFallbackMap } from "@xndrjs/i18n";
const localeFallback = {
en: null,
"de-CH": "en",
} satisfies LocaleFallbackMap;
const i18n = new IcuTranslationProviderMulti(schema, { localeFallback });projectDictionaryLocales / projectNamespaceLocales
Namespaces split the dictionary by domain; locale projection splits it by locale. Use before setAll() / setNamespace() when you only need one locale (or a small regional group) in memory.
import {
createI18n,
projectDictionaryLocales,
projectNamespaceLocales,
} from "./i18n/generated/instance.generated.js";
import { defaultDictionary } from "./i18n/generated/dictionary.generated.js";
import billingDictionary from "./i18n/translations/billing.json";
const i18n = createI18n(defaultDictionary);
i18n.setNamespace("billing", projectNamespaceLocales(billingDictionary, [activeLocale]));
i18n.setAll(projectDictionaryLocales(fullDictionary, [activeLocale])); // multi: all namespacesCodegen emits typed wrappers in instance.generated.ts (projectDictionaryLocales for the full schema; projectNamespaceLocales in multi mode for one namespace). With delivery: "custom", it also emits projectDictionaryForDeliveryArea and projectNamespaceForDeliveryArea. The low-level @xndrjs/i18n exports are *Core helpers for tooling without codegen.
6. Translation audit (xndrjs-i18n-audit)
Report missing translations as JSON — useful for translator backlogs and optional CI gates.
xndrjs-i18n-audit --config i18n/i18n.codegen.json
xndrjs-i18n-audit --config i18n/i18n.codegen.json --out audit.json
xndrjs-i18n-audit --config i18n/i18n.codegen.json --fail-on effectiverequiredLocales in the report match generated MyProjectLocale (dictionary locales ∪ fallback keys and targets).
| Field | Meaning |
| -------------------------- | ---------------------------------------------------------------------------------------- |
| missingDirectByLocale | No template for that locale on the key (or empty string when --allow-empty is not set) |
| missingEffectiveByLocale | Runtime would fail after walking LOCALE_FALLBACK — real coverage gaps |
By default the CLI does not fail (exit 0) — report-only. Pass --fail-on effective, direct, or any to exit 1 when gaps exist (for CI).
Locales such as de-CH that exist only in localeFallback (not in source files) typically show high missingDirect but low missingEffective when en covers the chain — that is expected.
Configuration (i18n/i18n.codegen.json)
Specify exactly one of dictionary (single-file) or namespaces (multi-file). Paths in the config are relative to the directory containing i18n.codegen.json (the i18n/ folder created by setup).
Multi-namespace
{
"namespaces": {
"default": "translations/default.json",
"user": "translations/user.json",
"billing": "translations/billing.yaml"
},
"typesOutput": "generated/i18n-types.generated.ts",
"dictionaryOutput": "generated/dictionary.generated.ts",
"instanceOutput": "generated/instance.generated.ts",
"paramsTypeName": "MyProjectParams",
"schemaTypeName": "MyProjectSchema",
"localeTypeName": "MyProjectLocale",
"factoryName": "createI18n",
"localeFallback": {
"en": null,
"de-DE": "en",
"de-CH": "de-DE",
"it": "en"
}
}Single-file
{
"dictionary": "translations/translations.json",
"defaultNamespace": "default",
"typesOutput": "generated/i18n-types.generated.ts",
"dictionaryOutput": "generated/dictionary.generated.ts",
"instanceOutput": "generated/instance.generated.ts",
"paramsTypeName": "MyProjectParams",
"schemaTypeName": "MyProjectSchema",
"localeTypeName": "MyProjectLocale",
"factoryName": "createI18n",
"localeFallback": {
"en": null,
"de-DE": "en",
"de-CH": "de-DE",
"it": "en"
}
}| Field | Description |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
| dictionary | Path to a single dictionary file (.json, .yaml, or .yml) for the flat API. Mutually exclusive with namespaces. |
| namespaces | Map of namespace -> dictionary path (.json, .yaml, or .yml) for the namespaced API. Mutually exclusive with dictionary. |
| defaultNamespace | Optional. Namespace label used internally in single-file mode (default "default"). Not exposed in the flat API. |
| typesOutput | Output path for the generated types. |
| dictionaryOutput | Optional. Output path for dictionary.generated.ts when codegen emits it (canonical delivery, or split/custom with eager namespaces). Omit in split/custom when every namespace is lazy — codegen does not write the file; if a stale manifest exists at the default {dirname(typesOutput)}/dictionary.generated.ts, it is removed. Explicit dictionaryOutput still controls which path is cleaned up. |
| instanceOutput | Output path for the generated factory (createI18n). |
| importExtension | Optional. Relative import suffix between generated .ts modules: "none" (default, extensionless), ".ts", or ".js". |
| factoryName | Name of the exported factory function (default createI18n). |
| paramsTypeName / schemaTypeName | Names of the exported types (customizable per project). |
| localeTypeName | Name of the exported locale union type (default MyProjectLocale). |
| localeFallback | Optional map of locale -> next locale | null for runtime fallback resolution. |
| localeFallbackConstName | Name of the generated fallback constant (default LOCALE_FALLBACK). |
| dictionarySchemaOutput | Optional path for generated external dictionary validation (dictionary-schema.generated.ts). Requires zod in the consumer app. |
| loadOnInit | Multi mode only; canonical delivery only. Namespaces to include in the initial bundle via static imports. When omitted, all namespaces are eager (default). Not allowed with split-by-locale or custom — those modes load every namespace through namespaceLoaders. |
| namespaceLoadersOutput | Output path for generated namespaceLoaders (dynamic import() per lazy namespace). Defaults to {dirname(instanceOutput)}/namespace-loaders.generated.ts. Required when lazy namespaces exist. |
| delivery | Optional. "canonical" (default) — one multilocale JSON per namespace. "split-by-locale" — emits {basename}.{locale}.json under {deliveryOutput}/translations/ and per-locale loaders. "custom" — named delivery areas via deliveryArtifacts; emits {basename}.{area}.json, area-scoped loaders, and typed DELIVERY_ARTIFACTS / LOCALE_DELIVERY_AREA in i18n-types.generated.ts. |
| deliveryArtifacts | Required when delivery is "custom". Map of delivery area → locale list (e.g. { "eu": ["en", "it"], "amer": ["en-US"] }). Codegen validates structure and that locales partition the project locale set. Emitted as DELIVERY_ARTIFACTS (area → locales) and LOCALE_DELIVERY_AREA (locale → area). |
| deliveryOutput | Optional. Directory for compiled and split delivery JSON (files land in {deliveryOutput}/translations/). Defaults to dirname(typesOutput). Use e.g. public/i18n to ship per-locale JSON from a static host while keeping generated TypeScript under generated/. |
Paths are resolved relative to the directory containing
i18n.codegen.json(e.g.i18n/when usingxndrjs-i18n-setup .).
YAML authoring
Dictionary paths may use .yaml or .yml instead of .json. YAML is an authoring format, not a runtime format — codegen compiles YAML to JSON under the delivery output directory (for example translations/billing.yaml → {deliveryOutput}/translations/billing.json; default {deliveryOutput} is dirname(typesOutput)); generated TypeScript imports the compiled JSON at runtime. Edit only the YAML source — the compiled JSON is overwritten on each codegen run.
Use YAML when ICU is hard to read on one line: multiple parameters, or plural/select branches. It is not aimed at long legal copy or styled pages — those usually belong in a separate content template with localized fragments inside.
YAML block scalars keep complex ICU readable in source while preserving (or intentionally folding) line breaks.
# translations/billing.yaml
appointment_summary:
en: |
Due {dueDate, date, short}
at {startTime, time, short}
it: "Scade il {dueDate, date, short} alle {startTime, time, short}"Workflow:
- Edit the
.yaml/.ymlsource file undertranslations/. - Run
xndrjs-i18n-codegen. - Commit the YAML source; the compiled JSON lives under
generated/translations/and is safe to commit or gitignore if CI regenerates it before build.
Mixed namespaces are supported: some namespaces can stay .json while others use YAML.
Split-by-locale delivery
By default (delivery: "canonical"), codegen keeps one multilocale JSON per namespace — either your source file in place (.json) or a compiled YAML → JSON under {deliveryOutput}/translations/ (default: generated/translations/).
Set "delivery": "split-by-locale" to emit one JSON file per locale instead. All namespaces are lazy in this mode — register them with namespaceLoaders before rendering:
{
"delivery": "split-by-locale",
"namespaces": {
"default": "translations/default.json",
"billing": "translations/billing.yaml"
},
"namespaceLoadersOutput": "generated/namespace-loaders.generated.ts"
}Codegen writes {deliveryOutput}/translations/{basename}.{locale}.json (for example user.it.json, billing.en.json). Each file contains the same shape as projectNamespaceLocalesCore(dict, [locale]) — keys map to a single locale entry. With localeFallback, locales such as de-CH are resolved at codegen time (for example from de-DE).
Authoring is unchanged — edit the canonical source JSON/YAML. Types (MyProjectSchema) are generated explicitly from ICU analysis (keys + Partial<Record<MyProjectLocale, string>> per key), not from typeof import of JSON files. Audit still reads the config paths.
Generated API changes (opt-in):
| Canonical | Split-by-locale / custom (all lazy) |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| export const defaultDictionary | dictionary.generated.ts is not generated |
| namespaceLoaders.billing() | namespaceLoaders.billing(locale) or namespaceLoaders.billing(area) |
| — | ensureNamespacesLoadedForLocale(i18n, locale) or ensureNamespacesLoadedForArea(i18n, area) in namespace-loaders.generated.ts |
| — | DELIVERY_ARTIFACTS, LOCALE_DELIVERY_AREA in i18n-types.generated.ts (custom only) |
When loadOnInit lists eager namespaces in canonical delivery only, dictionary.generated.ts still exports defaultDictionary with static imports. In split/custom delivery, eager slices use defaultDictionaryFor(locale) or defaultDictionaryFor(area) when configured.
Example init (multi, split-by-locale, all namespaces lazy):
import { createI18n } from "./generated/instance.generated.js";
import { ensureNamespacesLoadedForLocale } from "./generated/namespace-loaders.generated.js";
const i18n = createI18n({});
await ensureNamespacesLoadedForLocale(i18n, activeLocale);Example init (multi, custom delivery, all namespaces lazy):
import { createI18n } from "./generated/instance.generated.js";
import { ensureNamespacesLoadedForArea } from "./generated/namespace-loaders.generated.js";
const i18n = createI18n({});
await ensureNamespacesLoadedForArea(i18n, activeArea);Custom delivery config and typed artifacts (no need to read deliveryArtifacts from JSON at runtime):
{
"delivery": "custom",
"deliveryArtifacts": {
"eu": ["en", "it", "fr"],
"amer": ["en-US", "es-AR"]
},
"namespaces": {
"default": "translations/default.json",
"billing": "translations/billing.yaml"
},
"namespaceLoadersOutput": "generated/namespace-loaders.generated.ts"
}Codegen emits the partition in i18n-types.generated.ts:
export type MyProjectDeliveryArea = "amer" | "eu";
export const DELIVERY_ARTIFACTS = {
amer: ["en-US", "es-AR"] as const,
eu: ["en", "fr", "it"] as const,
} as const satisfies Record<MyProjectDeliveryArea, readonly MyProjectLocale[]>;
export type MyProjectDeliveryArtifacts = typeof DELIVERY_ARTIFACTS;
export const LOCALE_DELIVERY_AREA = {
"en-US": "amer",
"es-AR": "amer",
en: "eu",
fr: "eu",
it: "eu",
} as const satisfies Record<MyProjectLocale, MyProjectDeliveryArea>;import {
DELIVERY_ARTIFACTS,
LOCALE_DELIVERY_AREA,
type MyProjectDeliveryArea,
} from "./generated/i18n-types.generated.js";
const euLocales = DELIVERY_ARTIFACTS.eu; // readonly ["en", "fr", "it"]
const areaForEnUs: MyProjectDeliveryArea = LOCALE_DELIVERY_AREA["en-US"]; // "amer"Example dictionary.generated.ts (multi, canonical delivery, loadOnInit: ["default"]):
import defaultEn from "./translations/default.en.json";
import defaultIt from "./translations/default.it.json";
const defaultByLocale = {
en: defaultEn,
it: defaultIt,
} as const satisfies Record<MyProjectLocale, MyProjectSchema["default"]>;
export function defaultDictionaryFor(locale: MyProjectLocale): InitialSchema {
return { default: defaultByLocale[locale] };
}Example namespace-loaders.generated.ts:
export const namespaceLoaders = {
billing: (locale: MyProjectLocale) => {
switch (locale) {
case "en":
return import("./translations/billing.en.json").then((m) => m.default);
case "it":
return import("./translations/billing.it.json").then((m) => m.default);
case "de-CH":
return import("./translations/billing.de-CH.json").then((m) => m.default);
default:
throw new Error(
`[i18n] No translation artifact for namespace "billing" and locale "${String(locale)}".`
);
}
},
};
export async function ensureNamespacesLoadedForLocale(
i18n: I18nMultiInstance,
locale: MyProjectLocale,
namespaces: readonly LazyNamespace[] = ["billing", "default"] as const
): Promise<void> {
await Promise.all(
namespaces.map(async (namespace) => {
i18n.setNamespace(namespace, await namespaceLoaders[namespace](locale));
})
);
}Load only the namespaces you need:
import { createI18n } from "./generated/instance.generated.js";
import { ensureNamespacesLoadedForLocale } from "./generated/namespace-loaders.generated.js";
const i18n = createI18n({});
await ensureNamespacesLoadedForLocale(i18n, activeLocale, ["billing"]);Use split delivery when you want smaller lazy chunks (one locale per dynamic import) or when serving per-locale JSON from public/ without runtime projectNamespaceLocales. Runtime projectDictionaryLocales / projectNamespaceLocales remain available for external CMS/API payloads.
Usage
Single vs. multi-namespace API
| | Single-file | Multi-namespace |
| ----------- | ------------------------------ | --------------------------------------------- |
| I18N_MODE | 'single' | 'multi' |
| Provider | IcuTranslationProviderSingle | IcuTranslationProviderMulti |
| .get() | get(key, locale, params?) | get(namespace, key, locale, params?) |
| Override | setAll(schema) | setAll(schema) + setNamespace(ns, values) |
Multi-namespace example
import { i18n } from "./i18n"; // app singleton from i18n.ts
// or: import { createI18n, defaultDictionary } from './i18n'; const i18n = createI18n(defaultDictionary);
i18n.get("default", "login_button", "it"); // "Accedi"
i18n.get("default", "welcome", "en", { name: "Ada" }); // "Welcome Ada!"
i18n.get("default", "dashboard_status", "it", { msgCount: 3, chatCount: 2 });
i18n.get("billing", "invoice_summary", "en", { count: 12 });
// Compile-time errors:
i18n.get("default", "welcome", "it"); // ✗ missing { name }
i18n.get("billing", "login_button", "it"); // ✗ key not in namespaceSingle-file example
import { i18n } from "./i18n"; // app singleton from i18n.ts
// or: import { createI18n, defaultDictionary } from './i18n'; const i18n = createI18n(defaultDictionary);
i18n.get("login_button", "it");
i18n.get("welcome", "en", { name: "Ada" });Runtime override
// Full override — replaces the entire dictionary and clears the cache
i18n.setAll(externalPayload);
// Partial patch — updates a single namespace and invalidates only its cache
i18n.setNamespace("billing", externalBillingPayload);Lazy namespace loading (multi mode)
.get() stays synchronous — register lazy namespaces with setNamespace() before rendering. The pattern depends on delivery:
| Delivery | Loading pattern |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| canonical | loadOnInit for eager namespaces; manual namespaceLoaders.ns() for lazy ones. Use hasNamespace to skip already-loaded namespaces. |
| split-by-locale / custom | Every namespace is lazy. Prefer ensureNamespacesLoadedForLocale / ensureNamespacesLoadedForArea — they always call setNamespace() (safe when switching locale or area on a shared i18n instance). |
Canonical delivery — loadOnInit and manual loaders
Split namespaces across chunks with loadOnInit in canonical delivery only. Codegen emits typed namespaceLoaders with one dynamic import() per lazy namespace.
{
"delivery": "canonical",
"namespaces": {
"default": "translations/default.json",
"billing": "translations/billing.yaml"
},
"loadOnInit": ["default"],
"namespaceLoadersOutput": "generated/namespace-loaders.generated.ts"
}Codegen also emits LoadOnInitNamespace, LazyNamespace, and InitialSchema types. The generated factory accepts a partial InitialSchema at init time.
Use hasNamespace when loading lazily by hand — namespaces loaded once stay in memory:
import { i18n, namespaceLoaders, type LazyNamespace } from "./i18n";
i18n.get("default", "login_button", "en"); // available immediately
if (!i18n.hasNamespace("billing")) {
i18n.setNamespace("billing", await namespaceLoaders.billing());
}
i18n.get("billing", "invoice_summary", "en", { count: 12 });
// batch preload
await Promise.all(
(["user", "billing"] as const satisfies readonly LazyNamespace[]).map(async (namespace) => {
if (i18n.hasNamespace(namespace)) return;
i18n.setNamespace(namespace, await namespaceLoaders[namespace]());
})
);Split-by-locale / custom — ensureNamespacesLoaded*
With split-by-locale or custom, codegen emits ensureNamespacesLoadedForLocale / ensureNamespacesLoadedForArea. These helpers always reload the requested namespaces — do not guard with hasNamespace when switching locale or delivery area on the same instance:
import { createI18n } from "./generated/instance.generated.js";
import {
ensureNamespacesLoadedForLocale,
ensureNamespacesLoadedForArea,
} from "./generated/namespace-loaders.generated.js";
const i18n = createI18n({});
// split-by-locale
await ensureNamespacesLoadedForLocale(i18n, "it");
i18n.get("billing", "invoice_summary", "it", { count: 3 });
await ensureNamespacesLoadedForLocale(i18n, "en"); // reloads billing for en
i18n.get("billing", "invoice_summary", "en", { count: 3 });
// custom delivery
await ensureNamespacesLoadedForArea(i18n, "eu", ["billing"]);Route-scoped subset:
await ensureNamespacesLoadedForLocale(i18n, activeLocale, ["billing"]);Manual loader calls (without the helper) follow the same rule — call setNamespace again when the locale or area changes:
const locale = "it" as const;
i18n.setNamespace("billing", await namespaceLoaders.billing(locale));
// after locale change on the same instance:
i18n.setNamespace("billing", await namespaceLoaders.billing("en"));Generated loaders throw if locale/area does not match a known artifact (no silent undefined into setNamespace).
Optional locale projection before register:
import { projectNamespaceLocales } from "./i18n/generated/instance.generated.js";
const billing = await namespaceLoaders.billing();
i18n.setNamespace("billing", projectNamespaceLocales(billing, [userLocale]));Calling .get() on a namespace that is not loaded throws:
[i18n] Namespace not loaded: "billing". Register it with setNamespace() before calling .get().
When loadOnInit is omitted, behavior is unchanged: all namespaces are statically imported.
Fetch/CMS hydration is app-owned: fetch → validateExternalNamespace() (requires dictionarySchemaOutput + zod) → setNamespace().
External dictionary validation
When translations arrive from an external source (i.e. a CMS), validate the unknown input before calling setAll() or setNamespace().
Enable validation by adding dictionarySchemaOutput to i18n/i18n.codegen.json:
{
"dictionarySchemaOutput": "generated/dictionary-schema.generated.ts"
}Add zod as a dependency in the consumer app (required peer of @xndrjs/i18n).
Codegen emits DICTIONARY_SPEC and validateExternalDictionary(). Validation runs in two phases:
- Normalize — parse ICU templates, extract variables, check required keys (missing keys fail; extra keys are ignored; partial locales are OK).
- Validate — compare extracted arguments against the static
Paramsschema via Zod.
import { formatIssues } from "@xndrjs/i18n/validation";
import { validateExternalDictionary } from "./i18n/generated/dictionary-schema.generated.js";
import { i18n } from "./i18n";
const raw: unknown = await loadTranslations();
const result = validateExternalDictionary(raw);
if (!result.ok) {
console.error(formatIssues(result.issues));
return;
}
i18n.setAll(result.data);For a namespace patch (multi mode only):
import { validateExternalNamespace } from "./i18n/generated/dictionary-schema.generated.js";
const result = validateExternalNamespace("billing", rawBilling);
if (result.ok) {
i18n.setNamespace("billing", result.data);
}Low-level API is also available from @xndrjs/i18n/validation for custom wiring.
Provider API
Both providers share this behavior:
- Compilation cache — compiled
IntlMessageFormatinstances are cached per locale (and per namespace in multi mode). getAll()— returns a deep-frozen snapshot of the current dictionary (not a live reference).hasNamespace(ns)— (multi only) returns whether a namespace has been loaded (eager init, lazy load, orsetNamespace).setAll(values)— replaces the dictionary and clears the entire cache.setNamespace(ns, values)— (multi only) replaces one namespace and invalidates only its cache entries.- Missing key/locale — by default throws an error if the template is
undefined(configurable viaonMissing, see below). An empty string ("") is treated as a valid template. - ICU syntax error — throws
[i18n ICU Syntax Error] .... - Formatting error (missing/invalid params) — throws
[i18n Formatting Error] ....
Missing-translation strategy (onMissing)
Both providers accept an onMissing option controlling what happens when no template resolves for a key/locale (after walking the full fallback chain):
onMissing?: "throw" | "key" | ((context: MissingTranslationContext) => string);
// MissingTranslationContext = { namespace?: string; key: string; locale: string; fallbackChain: string }"throw"(default) — throws[i18n] Missing key or locale: ...including the fallback chain."key"— returns the key itself:keyin single mode,namespace.keyin multi mode.- Function — receives the missing-translation context (
namespaceis only set in multi mode) and returns the string to display.
const i18n = new IcuTranslationProviderMulti(schema, {
localeFallback,
onMissing: ({ namespace, key }) => `[missing: ${namespace}.${key}]`,
});onMissing only applies to missing-template resolution. ICU syntax errors, formatting errors, and (in multi mode) unloaded namespaces still throw.
The generated factory accepts onMissing as a pass-through option, merged with the codegen-wired localeFallback:
import { createI18n } from "./i18n/generated/instance.generated.js";
const i18n = createI18n(dictionary, { onMissing: "key" });Commands
Run from the repo root:
# Install workspaces
pnpm install
# Build the library
pnpm --filter @xndrjs/i18n build
# Generate i18n types/dictionary/instance for the demo app
pnpm --filter @xndrjs/i18n-demo i18n:codegen
# Type-check the i18n package
pnpm --filter @xndrjs/i18n typecheck
# Run the demo app (single + multi)
pnpm --filter @xndrjs/i18n-demo demo
# Run library tests
pnpm --filter @xndrjs/i18n testFrom inside apps/i18n-demo/:
pnpm run i18n:codegen:single # xndrjs-i18n-codegen --config single/src/i18n/i18n.codegen.json
pnpm run i18n:codegen:multi # xndrjs-i18n-codegen --config multi/src/i18n/i18n.codegen.json
pnpm run i18n:audit:single # xndrjs-i18n-audit --config single/src/i18n/i18n.codegen.json
pnpm run i18n:audit:multi # xndrjs-i18n-audit --config multi/src/i18n/i18n.codegen.json
pnpm run demo:single # tsx single/src/index.ts
pnpm run demo:multi # tsx multi/src/index.tsAdding a translation key
- Add the key with its ICU strings to the relevant JSON file under
translations/. - Run
pnpm --filter @xndrjs/i18n-demo i18n:codegen:multi(ori18n:codegen:single). - The generated
MyProjectParamsupdates; TypeScript will flag any.get()call sites that now need (or no longer need) parameters.
Adding a namespace
- Create a new dictionary file (
.json,.yaml, or.yml, any path). - Add it to
namespacesini18n/i18n.codegen.json. - Re-run codegen.
dictionary.generated.tswires the import automatically.
Migrating single-file to multi-namespace
- Split the flat JSON into per-domain files.
- Change
dictionarytonamespacesin the config. - Re-run codegen — types and API switch from flat to namespaced.
- Update call sites:
get('key', locale)→get('namespace', 'key', locale).
Tech stack
- TypeScript 6 (
strict,resolveJsonModule,moduleResolution: "bundler") - intl-messageformat — runtime ICU formatting
- @formatjs/icu-messageformat-parser — build-time AST parsing
- zod — peer dependency (config validation, external dictionary validation)
- tsx — runs TypeScript scripts directly
