Skip to content
White / wsuites

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.