Practitioner 2 min read
Typed translations: making a missing string a build error
Two languages from one static build, where TypeScript refuses to compile if a translation is missing instead of quietly falling back to English.
Published
The usual way to add a second language is a dictionary of string keys and a t()
function:
t("home.hero.title");
It works, and it fails quietly. Rename a key and you get undefined on a page.
Forget a translation and the visitor silently reads English on a Spanish URL.
Nothing breaks loudly enough for you to notice before a reader does.
Make the compiler do the checking
The alternative costs nothing extra and removes the whole class of problem: make the English copy an ordinary object, derive a type from it, and require every other language to satisfy that type.
// copy/en.ts: the source of truth
export const en = {
nav: { work: "Work", writing: "Writing", about: "About" },
cta: {
lead: "Free consultation",
action: "Discuss a project",
},
};
export type Copy = typeof en;
// copy/es.ts: checked against it
import type { Copy } from "./en";
export const es: Copy = {
nav: { work: "Trabajo", writing: "Notas", about: "Perfil" },
cta: {
lead: "Consultas gratis",
action: "Hablemos del proyecto",
},
};
Miss a key and tsc fails with the exact path. Rename one in English and every
locale that has not caught up fails too. There is no string-key indirection to
get wrong, because there are no string keys, just property access that your
editor autocompletes:
<h1>{copy.home.title}</h1>
One detail matters: do not write as const on the English object. With
as const, every string narrows to its own literal type and the Spanish file
would be required to contain the identical English text. Without it, the types
widen to string and string[], so the structure is checked and the content is
free, which is exactly the contract you want.
Routing: default language unprefixed
English stays at /, Spanish at /es/. Existing URLs keep working, which
matters more than symmetry:
i18n: {
defaultLocale: "en",
locales: ["en", "es"],
routing: { prefixDefaultLocale: false },
}
Three small pure functions handle everything else, and they are worth writing carefully because every link on the site passes through them:
export function localizePath(path: string, locale: Locale): string {
const clean = stripLocale(path);
if (locale === DEFAULT_LOCALE) return clean;
return clean === "/" ? `/${locale}` : `/${locale}${clean}`;
}
stripLocale runs first so localizePath is idempotent. Passing an
already-localized path in returns the correct result rather than /es/es/about.
Content, and being honest about gaps
Long-form content lives one directory per language:
src/content/blog/
├── en/typed-translations-static-build.mdx
└── es/typed-translations-static-build.mdx
An entry id becomes en/typed-translations-static-build, so the loader is
unchanged and the locale is just the first path segment.
Translations of long articles lag behind, and that is normal. The part worth designing is what happens when one is missing. Falling back to English is correct; doing it silently is not:
const translated = byLocale.get(slug);
const entry = translated ?? fallback.get(slug);
posts.push({ entry, slug, translated: Boolean(translated) });
The translated flag travels with the entry, and the page renders a short
notice when it is false. The reader is told which language they are actually
reading instead of being left to work it out.
What this does not solve
Type checking guarantees a string exists. It cannot tell you the string is a good translation, or that a date reads naturally, or that a sentence still fits its button in German. Those need a human.
What it does buy is that the failure mode changes from “a visitor finds it” to “the build refuses”. That is the trade I want on every project: push errors as early as they will go, and accept that the remaining ones are the interesting kind.