Skip to content
edge-language-tools
Esc
navigateopen⌘Jpreview
On this page

Typed i18n keys

Typo a translation key, get a squiggle instead of a blank string in production.

Missing translation keys are the quietest bug class in template land: the page renders, the string is just empty (or the raw key leaks to the user). Type the translator function against a union of real keys and the whole class disappears.

  • locales/
    • en.json
    • es.json
  • i18n.types.ts
  • templates/
    • checkout.edge

Derive the key union from the messages themselves

import type en from './locales/en.json'

// Every key of the English catalog, kept in sync automatically.
export type MessageKey = keyof typeof en

export type Translator = (key: MessageKey, params?: Record<string, string | number>) => string
{
  "nav.home": "Home",
  "nav.profile": "Profile",
  "checkout.confirm": "Confirm purchase",
  "checkout.total": "Total: {amount}"
}

No codegen at all — keyof typeof over the imported JSON is the manifest. Add a key to en.json, it’s instantly in the union (resolveJsonModule in tsconfig).

Templates declare the translator as a prop

{{--
@types {
  t: import('../i18n.types.ts').Translator
  total: number
}
--}}
<button>{{ t('checkout.confirm') }}</button>
<p>{{ t('checkout.totale', { amount: total }) }}</p>

'checkout.totale' squiggles — not assignable to type ‘MessageKey’ — and inside the quotes the editor lists every real key, namespaced and searchable. Delete a key from en.json and every template still using it errors in CI.

The compounding effect

Combine with the other examples and the template layer becomes fully navigable: routes, model fields, state variants, and message keys all autocomplete from one source of truth each — and edge-check in CI guards all of them at once. The common thread is always the same move: find the stringly-typed seam, replace it with a literal union, let the existing checker do the rest.

Was this page helpful?