Skip to content
Avence Labs
Back to Blog
TypeScript

Making a Missing Translation a Compile Error, Not a Runtime Surprise

Published April 27, 20265 min read

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.