Making a Missing Translation a Compile Error, Not a Runtime Surprise
Every multi-language site eventually ships a page with a blank space where a translation should be. Usually it's not a translation mistake — it's a key that was added to the primary language file and never copied to the other three, and nothing in the toolchain noticed until a user did.
The usual approaches, and why we skipped them
The common fixes are a linter script that diffs key sets across JSON files (runs in CI, catches drift a commit late), or a runtime fallback that silently shows the English string when a translation is missing (hides the bug instead of fixing it, and a "temporarily" missing string has a way of staying missing). Both treat the dictionary's shape as data to be checked, rather than as a type to be enforced.
What we did instead
The dictionaries are plain TypeScript objects, one per language, and the English file is canonical:
const en = {
hero: { headline: "...", body: "..." },
nav: { items: ["Home", "About", "Contact"] },
} as const;
Every other locale is written as satisfies Dictionary, where Dictionary is derived from the English object itself using a recursive mapped type:
type Widen<T> = T extends string ? string
: T extends number ? number
: T extends object ? { readonly [K in keyof T]: Widen<T[K]> }
: T;
export type Dictionary = Widen<typeof en>;
Widen walks the English dictionary's structure and replaces every literal string with the general string type — so a translation is free to use completely different words, while the shape of the object it lives in is locked to match English exactly, key for key, nesting level for nesting level.
Why the tuple case matters
The mapped type runs over arrays the same way it runs over objects, which means it preserves length, not just key names. nav.items above is a 3-tuple of strings in the English file, so Dictionary["nav"]["items"] is also a 3-tuple — not string[]. If a translated file lists two items or four, TypeScript reports Source has 2 element(s) but target requires 3 at the exact call site, during next build, before anything ships. We rely on this constantly: navigation menus, feature checklists, and stat rows are all authored as fixed-length tuples specifically so a translator can't accidentally drop or duplicate an entry without the build catching it.
The trade-off
This buys compile-time certainty at the cost of needing every locale file to be a complete, well-typed TypeScript module rather than a simpler JSON file a non-engineer could hand-edit. For a four-language site maintained by the same team that owns the codebase, that trade was worth making. For a much larger localization operation with external translators editing strings directly, a schema-validated JSON pipeline would probably be the better call — the point isn't that this specific technique is universally correct, it's that the dictionary's shape is exactly the kind of invariant a type system is good at holding, and it's worth asking whether yours currently is.