A French Catalog That Still Says Save Passes the Type Check Perfectly
Record<Locale, typeof en> makes shipping a half translated screen a compile error across 14 languages, and does nothing at all about a locale file that is structurally perfect and still in English.
A French catalog whose save is still the English “Save” compiles. So does a translator’s typo. So does a {name} placeholder left as {nom}, which then renders to a French user as a literal {nom} in the middle of a sentence.
All three are valid strings of the right type in the right place, and the compiler is completely satisfied, and it’s right to be. i want to start there because most writing about type-safe i18n starts at the other end, with the trick, and the trick genuinely works. It just doesn’t buy what people say it buys.
The machine
We run 14 languages across a web app and a native app. English is a normal deeply nested object that exports its own type.
export const en = {
common: { buttons: { save: 'Save', cancel: 'Cancel' } },
}
export type Messages = typeof en And then one line does all the work.
const CATALOGS: Record<Locale, typeof en> = {
en,
es,
fr,
de,
zh,
hi,
pt,
it,
ja,
te,
gu,
ko,
bn,
ru,
} Record<Locale, typeof en> checks all 14 catalogs against the exact shape of English. Add common.buttons.rename to en and turbo typecheck fails for all 13 others until each supplies a rename of the right type. Delete a key from fr, or fat-finger common.buttosn, and the build breaks at that catalog with the path in the error.
So English is the schema and the compiler is the migration tool that won’t let the catalogs drift. No linter, no CI script diffing JSON files, no discipline required from anybody.
That last part is the real win and it’s worth being precise about why. A runtime “missing key” warning and a CI script that compares catalogs both detect the problem, which means both of them detect it after you’ve already written the bug and pushed it. This design makes the bug unwritable. The feedback lands on the author’s machine before the commit exists, which is the cheapest moment it can possibly land.
The lookup is deliberately blunt
const lookup = (catalog, path, params) => {
let cursor = catalog
for (const seg of path.split('.')) {
if (cursor && typeof cursor === 'object' && seg in cursor)
cursor = cursor[seg]
else return path
}
if (typeof cursor !== 'string') return path
return params
? cursor.replace(/{(w+)}/g, (_, k) =>
k in params ? String(params[k]) : `{${k}}`,
)
: cursor
} Note return path on a miss. A missing key renders as the literal string common.buttons.save on screen rather than crashing.
And there’s no English fallback there, on purpose. A fallback would make a missing key look completely fine in testing and only break for users in other languages, which is the worst possible place to discover it. The raw path looks like a bug on a real screen, because it is one. The type machine means that branch is effectively unreachable anyway, so it’s a belt under the suspenders and it stays ugly for a reason.
What it doesn’t catch
The machine guarantees completeness of keys. Every locale has every key, of the right type. That’s the whole guarantee and it’s a good one.
It says nothing about the values. The compiler proves the structure is complete. It can’t prove the content was translated, or translated accurately, or that it’s even in the intended language.
So the failure mode moves rather than disappearing. The class where a missing key renders as mobile.x.y is gone entirely and forever. What’s left is present-but-untranslated, and present-but-wrong, and only a human reading it in context ever catches those.
Types buy you completeness for free and forever. They buy you nothing on correctness. Anybody selling type-safe i18n as solving translation is selling you half of it, and it happens to be the half that’s easy.
The workflow that falls out of admitting that
Once you accept the boundary, the process writes itself. When you add a key, you script the insertion across all 14 catalogs with the English value in every one, ship it, and queue the real translations behind it.
Everything renders, nothing breaks, and users in other languages transiently see English for that one string. The type machine is what makes that safe rather than reckless, because you physically cannot forget a locale while doing it, and the incremental translation history shows exactly this cadence. Chat components, then profile and messages and notifications, then toasts, then error helpers, then aria-labels, then email templates, then property detail keys. Each one landing across all 14 locales in one pass.
Two things outside the type system
The first is formatting, which is a genuinely separate axis. The catalogs localize strings and that’s all they do. Numbers, dates, and currency go through a separate map of BCP-47 tags, en to en-US, hi to hi-IN, pt to pt-BR, fed to Intl.NumberFormat and Intl.DateTimeFormat.
Translating the word “Total” is the catalog’s job. Rendering ₹ against $ against €, or 1,000.00 against 1.000,00, is Intl’s job keyed by the tag. Hardcoding a currency symbol inside a translated string is the exact bug this separation exists to prevent, and it’s an easy one to write, because the string is right there and it looks like it belongs.
The second is a rendering fact no type system can reach. A language picker begs for emoji flags and they’re a trap: Emoji country flags don’t render on Windows at all. Segoe UI Emoji ships no flag glyphs, so they fall back to bare letters or tofu boxes. We use a real SVG FlagIcon keyed by ISO-3166 alpha-2 instead.
That one isn’t the kind of thing any amount of type safety would’ve caught, because the types were perfect and the string was correct and the glyph just wasn’t in the font. It belongs in the same family as the schema field that was pulling a model into inventing dates, where everything type-checks and the output is still wrong.
The trade
Every new user-facing string is a 14-file change. Forget one and the build fails, which is the point, and it’s still friction, and i’m not going to pretend otherwise.
What you get is a failure mode you never think about again. No French user will ever see common.buttons.save because somebody added a key to English and forgot fr.
Whether that trade is worth it depends entirely on how many locales you actually ship. At 2 it’s obviously not, you’d just diff the files. At 14 it obviously is. i don’t know where it flips and i suspect the honest answer is that it flips the first time a translator quits mid-sprint.
