20 KiB
lang
Type-safe i18n library for SvelteKit. Zero external dependencies. Reactive
translation resolution with fallback chain, pluralization via
Intl.PluralRules, cross-key references, and full JSON serialization.
Architecture
lang/
├── index.ts Barrel exports
├── types.ts LangBase, SupportedLocale, LangRecord, LangNode, ...
├── engine-lang.ts Pure factory: createEngineLang()
├── active-lang.svelte.ts Svelte 5 reactive wrapper: createActiveLang()
├── mono-lang.svelte.ts Single-language passthrough: createActiveMonoLang()
├── plural.ts p() helper, WeakMap-based config storage
├── plural_rules.ts Intl.PluralRules wrapper
├── guards.ts Type guards: isLangRef, isLangRecord, isLangString
├── helpers.ts resolvePath, interpolateTemplate, resolveLocaleInRecord, deepMerge
├── json.ts langNodeToJSON / JSONToLangNode (round-trip)
├── consts.ts ID_PREFIX (#?), separators, limits
├── errors.ts Centralized error messages
└── test/
└── lang.test.ts Unit tests (vitest, node env)
Design principles
- Pure engine —
createEngineLang()has no reactive state of its own.t()andts()require an explicitlocale. No hidden mutation. - Reactive wrapper —
createActiveLang()(in.svelte.ts) owns the locale via$state, injecting it into each engine call. The engine stays framework-agnostic so non-Svelte consumers can build their own adapter. - Mono variant —
createActiveMonoLang()for monolingual apps that want the samet()/ts()call sites without paying for a translation table. Returns paths verbatim (or|fallbackliterals), warns once per unresolved path in DEV. - BCP 47 locales —
SupportedLocale = LangBase | \{LangBase}-{string}``. Resolution per record: exact → region→base → bare→first sibling → fallback chain. - Fallback chain —
[currentLocale, ...fallbackChain, defaultLocale]. - Type-safe paths —
t('common.ok')autocompletes and validates types. A dynamicstringoverload is available for runtime paths. - Decoupled plural data —
p()lives inplural.ts, not in the engine; translation data never imports from the engine.
Alias
Configured in svelte.config.js:
alias: { $lang: 'src/arts/lang' }
Imports:
import { createEngineLang } from '$lang';
import { createActiveLang } from '$lang/active-lang.svelte';
import { createActiveMonoLang } from '$lang/mono-lang.svelte';
createActiveLang and createActiveMonoLang live in .svelte.ts files — they own
$state and must be imported from the file directly, not from the barrel
(which stays runes-free for SSR/Node consumers).
Schema
A schema is a tree of LangNode with four leaf types:
LangRecord — static translations
{ es: 'Aceptar', en: 'OK', ar: 'حسناً' }
All locale keys are optional — no locale is hardcoded.
LangFn — parametric interpolation
(params: { name: string }) => ({
es: `Hola, ${params.name}`,
en: `Hello, ${params.name}`
});
At serialization time (langNodeToJSON), a proxy captures the tokens so the
JSON contains "Hola, {{name}}" regardless of whether the function uses
${...} or {{...}}.
LangPluralFn — pluralization with p()
import { p } from '$lang';
p({
es: { one: '{{count}} mensaje', other: '{{count}} mensajes' },
en: { one: '{{count}} message', other: '{{count}} messages' },
ar: {
zero: 'لا توجد رسائل',
one: 'رسالة واحدة',
two: 'رسالتان',
few: '{{count}} رسائل',
many: '{{count}} رسالة',
other: '{{count}} رسالة'
}
});
Internally uses Intl.PluralRules. Forms: zero, one, two, few,
many, other. Only other is required; missing forms fall back to
other.
LangRef — alias to another key
'#?common.ok'; // resolves the value at `common.ok`
'#?common.ok|fallback'; // literal fallback if the key does not exist
Prefix #?, separator | for the fallback literal. Up to 3 levels of
indirection; cycles throw.
Full example
import { p } from '$lang';
import type { LangNode } from '$lang';
export const translations = {
common: {
ok: { es: 'Aceptar', en: 'OK' },
cancel: { es: 'Cancelar', en: 'Cancel' }
},
greet: (params: { name: string }) => ({
es: `Hola, ${params.name}`,
en: `Hello, ${params.name}`
}),
messages: {
unread: p({
es: { one: '{{count}} mensaje sin leer', other: '{{count}} mensajes sin leer' },
en: { one: '{{count}} unread message', other: '{{count}} unread messages' }
})
},
ref: '#?common.ok'
} satisfies LangNode;
export type TranslationSchema = typeof translations;
Usage
Pure engine (non-Svelte)
import { createEngineLang } from '$lang';
import { translations } from './translations';
const lang = createEngineLang(translations, 'es');
// `t()` and `ts()` require an explicit locale on the pure engine.
lang.t('common.ok', undefined, 'es'); // 'Aceptar'
lang.t('greet', { name: 'Ana' }, 'en'); // 'Hello, Ana'
lang.t('messages.unread', { count: 5 }, 'en'); // '5 unread messages'
lang.ts({ es: 'Hola', en: 'Hello' }, 'en'); // 'Hello'
lang.ts('#?common.ok', 'es'); // 'Aceptar'
ts() and the LangString type
t() resolves translations by path from the schema. ts() takes a
LangString value directly — useful when the translation is built inline
or flows through props/context. The value can be any of the three variants:
type LangString = string | LangRecord | LangRef;
This is the idiomatic way to accept "maybe translatable" text through
component boundaries: a prop typed message: LangString accepts all three
shapes transparently, and components call lang.ts(message) to render.
// 1) Plain string — passthrough, no lookup
lang.ts('Static text', 'en'); // 'Static text'
// 2) LangRecord — inline per-locale values
lang.ts({ es: 'Guardar', en: 'Save', ar: 'حفظ' }, 'en'); // 'Save'
lang.ts({ es: 'Solo español' }, 'en'); // 'Solo español' (single-locale fallback)
// 3) LangRef — alias to a schema key (optional literal fallback)
lang.ts('#?common.ok', 'es'); // resolves to 'Aceptar'
lang.ts('#?missing.key|Default', 'es'); // fallback literal wins
Practical pattern — a generic button component that accepts a translatable label:
<!-- MyButton.svelte -->
<script lang="ts">
import type { LangString } from '$lang';
import { getContext } from 'svelte';
import type { ActiveLang } from '$lang';
let { label, onclick }: { label: LangString; onclick: () => void } = $props();
const lang = getContext<ActiveLang>('lang');
</script>
<button {onclick}>{lang.ts(label)}</button>
Callers pass whichever form fits:
<MyButton label="Plain text" onclick={...} />
<MyButton label={{ es: 'Guardar', en: 'Save' }} onclick={...} />
<MyButton label="#?actions.save" onclick={...} />
The three guards help narrow a LangString when needed:
import { isLangRef, isLangRecord, isLangString } from '$lang';
if (isLangRef(value)) { /* value is `#?...` */ }
if (isLangRecord(value)) { /* value is { es?, en?, ar?, ... } */ }
if (isLangString(value)) { /* value is one of the three */ }
Reactive wrapper for Svelte 5
<script lang="ts">
import { createActiveLang } from '$lang/active-lang.svelte';
import { translations } from './translations';
const lang = createActiveLang(translations, 'es');
</script>
<h1>{lang.t('common.ok')}</h1>
<button onclick={() => lang.setLocale('en')}>EN</button>
<button onclick={() => lang.setLocale('ar')}>AR</button>
createActiveLang holds locale in $state. When you call
setLocale(x), every t() / ts() / getLocale() consumed in a
component (or inside $derived / $effect) re-evaluates.
Signatures on the reactive wrapper make locale optional — it defaults
to the active reactive locale:
lang.t('common.ok'); // uses active locale
lang.t('common.ok', undefined, 'en'); // explicit locale override
Fallback chain
// Resolution order: currentLocale → en → es (default)
const lang = createActiveLang(translations, 'es', ['en']);
lang.setLocale('fr');
lang.t('common.ok'); // fr missing → tries en → 'OK'
Dynamic modules — extend()
Adds translations to the schema at runtime. Supports dotted namespaces and deep-merges existing keys:
lang.extend('shop', {
product: { es: 'Producto', en: 'Product' }
});
lang.t('shop.product'); // 'Producto'
// Dotted namespace — creates nested structure
lang.extend('app.settings', {
title: { es: 'Ajustes', en: 'Settings' }
});
// Subscribe to schema changes (triggers re-render in Svelte automatically)
const unsub = lang.onSchemaChange(() => {
// schema changed
});
Collision behavior
extend() is a "last write wins" operation. Two distinct collision modes:
- Branch ↔ leaf (incompatible shape): throws
TypeError. Trying to extend below an existing translation leaf, or replace a branch with a leaf, is rejected loudly because the resulting shape is broken. - Leaf ↔ leaf (silent overwrite): the new value replaces the existing
one and a DEV warning is emitted under category
lang—extend(): leaf at "<path>" was overwritten silently. Last write wins. The intended use ofextend()is route-level lazy loading where each module owns a unique namespace; collisions usually indicate two modules registering the same key by mistake.
lang.extend('common', { ok: { es: 'Sí', en: 'Yes' } });
// DEV: [lang] extend(): leaf at "common.ok" was overwritten silently.
// Last write wins — register namespaces uniquely or check for
// collisions before extending.
If your build pipeline can prove namespaces are unique (for example, every route module declares its prefix and a build step verifies disjointness), you can ignore the warning. Otherwise treat it as the bug it usually is.
Child instances — register()
Creates a new instance with the module merged. In the reactive wrapper the child shares locale state with the parent:
const child = lang.register('admin', {
dashboard: { es: 'Panel', en: 'Dashboard' }
});
child.t('admin.dashboard'); // 'Panel'
child.t('common.ok'); // 'Aceptar' (inherits parent schema)
lang.setLocale('en');
child.getLocale(); // 'en' (synced)
child.dispose(); // detach from parent
lang.setLocale('es');
child.getLocale(); // 'en' (stopped syncing)
Mono lang — single-language passthrough
When the app does not need translation, use createActiveMonoLang() to get an
ActiveLang-shaped instance whose t() / ts() methods are passthrough:
import { createActiveMonoLang } from '$lang/mono-lang.svelte';
const lang = createActiveMonoLang();
lang.t('common.ok'); // 'common.ok'
lang.t('common.ok|Aceptar'); // 'Aceptar'
lang.t('greet|Hello {{name}}', { name: 'Ada' }); // 'Hello Ada'
lang.ts('plain text'); // 'plain text'
lang.ts({ es: 'Hola', en: 'Hello' }); // 'Hola' (first non-empty entry)
The locale is held as $state so consumers (Formats, Frontend) that
subscribe via onLocaleChange still react to setLocale.
In DEV the mono lang warns once per unresolved path via the supplied logger
under category lang.mono — strong signal that the caller forgot to
configure a real lang or to add |fallback text. Calls that include
|fallback are silent.
const lang = createActiveMonoLang({ logger: app.Logger });
lang.t('users.profile.name'); // warns once
lang.t('users.profile.name'); // dedup, no second warn
lang.t('users.profile.email'); // different path → warns once
Mono lang is consumed automatically by createActiveApp({ /* no lang */ })
so monolingual apps never have to wire it themselves.
Mono lang type contract
createActiveMonoLang() is public, but intentionally type-loose: there is
no schema, so it cannot provide typed path autocomplete or static missing-key
checks. createActiveApp<S>() casts the mono implementation to
ActiveLang<S> when the app is created without lang, only to keep the root
surface uniform (App.Lang.t(...) always exists).
Treat that cast as a null-object convenience, not as a type guarantee:
- Use
createActiveApp({ lang: { schema, defaultLocale } })when translation key correctness matters. - Use mono-lang for monolingual apps, prototypes or apps that deliberately want literal/fallback strings.
- Add
|fallbacktext for user-facing strings so missing i18n configuration does not paint internal paths in the UI.
API — EngineLang
| Method | Description |
|---|---|
t(path, params, locale) |
Translate by schema path. Params for interpolation/plural. |
ts(value, locale) |
Translate a direct LangString (record, string or ref). |
onSchemaChange(fn) |
Subscribe to schema mutations (extend). Returns unsub. |
extend(namespace, module) |
Add a module at runtime via deep-merge. Dotted namespaces. |
register(namespace, module) |
Return a child instance with the merged module. |
setLogger(logger) |
Inject a custom logger. Set-once. |
getDefaultLocale() |
Return the default locale of the instance. |
getFallbackChain() |
Return the configured fallback chain (or undefined). |
API — ActiveLang (Svelte)
In addition to the above (with locale made optional on t / ts):
| Method | Description |
|---|---|
getLocale() |
Return the active locale (reactive). |
setLocale(locale) |
Change the active locale. |
onLocaleChange(fn) |
Subscribe to locale changes. Returns unsub. |
dispose() |
Detach a child from its parent locale stream. |
Types
| Type | Description |
|---|---|
LangBase |
Closed set of base languages: es | en | ar | de | fr | it | pt | ca | eu | gl |
SupportedLocale |
BCP 47: LangBase | ${LangBase}-${string} — accepts 'es', 'es-MX', 'pt-BR', ... |
LangRecord |
{ [K in SupportedLocale]?: string } — all optional, accepts both bare and regional keys |
LangString |
string | LangRecord | LangRef |
LangNode |
Recursive: LangValue | LangBranch |
LangValue<P> |
LangRecord | LangFn<P> | LangPluralFn<P> | LangRef |
LangBranch |
{ [key: string]: LangNode } — named branch |
LangRef |
Template literal: `#?${string}` |
LangFn<P> |
(params: P) => LangRecord |
LangPluralFn<P> |
(params: { count: number } & P) => LangRecord |
LangParams |
Record<string, unknown> — canonical params type |
PluralConfig |
{ [K in SupportedLocale]?: PluralForms } |
PluralForms |
{ other: string } & Partial<Record<PluralCategory, string>> |
EngineLang<S> |
Pure engine public interface |
ActiveLang<S> |
Reactive wrapper public interface |
Logger |
Shared logger contract from $libs/logr; setLogger(logger) is set-once and diagnostics flow through LangDiagnostics |
Fallback behavior
When a key is translated, locales are tried in this order:
1. currentLocale
2. fallbackChain[0]
3. fallbackChain[1]
4. ...
5. defaultLocale
Example with createEngineLang(schema, 'es', ['en']) and
setLocale('fr'):
key: common.ok → { es: 'Aceptar', en: 'OK' }
1. fr → missing
2. en → 'OK' ✓
If every locale is missing, t() returns the path (or the |fallback
literal if present) and ts() returns the empty string. In DEV a warning
is emitted when a fallback is used.
BCP 47 resolution per record
Each link of the fallback chain is matched against the record using these rules, in order:
- Exact match.
'es-MX'findsrecord['es-MX']if present. - Region → base.
'es-MX'falls back torecord['es']when the regional entry is missing. - Bare → first sibling.
'es'finds the firstrecord['es-XX']in insertion order when no bare'es'entry exists. With more than one sibling regional variant a DEV warning is emitted asking the author to define the canonical'es'entry to remove the ambiguity. - Otherwise the next chain link is tried.
Note that regional locales never sibling-search: requesting 'es-CL'
on a record that only has 'es-MX' does not match — it falls through to
the chain. Two regional variants of the same base are considered distinct
editorial decisions.
const record = { en: 'Hello', 'es-MX': 'Qué onda', 'pt-BR': 'Oi' };
resolve(record, 'es-MX'); // 'Qué onda' — exact
resolve(record, 'en-GB'); // 'Hello' — region → base
resolve(record, 'es-AR'); // missing — no exact, no base, no sibling search
resolve(record, 'pt'); // 'Oi' — bare → only sibling
resolve(record, 'es'); // 'Qué onda' — bare → first sibling (single match, no warning)
With multiple siblings the warning fires:
const onlySiblings = { 'es-MX': 'Qué onda', 'es-AR': 'Hola che' };
resolve(onlySiblings, 'es');
// 'Qué onda' — picked by insertion order
// DEV warn: requested base "es" but only 2 regional variants exist;
// define a canonical "es" entry to remove the ambiguity.
JSON round-trip
import { langNodeToJSON, JSONToLangNode } from '$lang';
const json = langNodeToJSON(translations);
// LangFn → { es: 'Hola {{name}}', en: 'Hello {{name}}' }
// LangPluralFn → { __type: 'plural', config: { ... } }
// LangRef → '#?common.ok' (plain string)
// LangRecord → { es: 'Aceptar', en: 'OK' } (unchanged)
const restored = JSONToLangNode(json);
// Interpolation and plural functions are reconstructed.
// References stay as strings (isLangRef() recognises them).
Tests
npx vitest run src/arts/lang/test/lang.test.ts
Covers: guards, helpers, plural rules, p() with WeakMap, engine (t/ts,
locale param, fallback chain, extend, register, setLogger), JSON
round-trip, and confirmation that no locale is hardcoded.