Skip to content

i18n Pattern

Translation handling with i18next.

Structure

src/i18n/
├── english.ts    # English translations — schema source of truth
├── czech.ts      # Czech translations — structurally checked against english
├── keys.ts       # Derives TranslationKeys from english.ts
└── language.ts   # i18next configuration

Translations

english.ts is the single schema source of truth:

// english.ts
export const english = {
  shared: {
    button: {
      save: "Save",
      cancel: "Cancel",
      delete: "Delete",
    },
    aria: {
      close: "Close",
      menu: "Open menu",
    },
  },
  markers: {
    title: "Markers",
    created: "Marker created",
    deleted: "Marker deleted",
  },
};

czech.ts must mirror the same structure — enforced at compile time:

// czech.ts
export const czech = {
  ...
} satisfies typeof english;

Translation Keys

TranslationKeys is derived from english.ts at runtime — keys.ts recursively walks the object and replaces every leaf with its dot-path. Never hand-edit key paths.

// keys.ts (derivation, not a hand-maintained map)
export const TranslationKeys = buildKeys(english, "") as TranslationKeyTree<typeof english>;

TranslationKeys.shared.button.save; // "shared.button.save"

The shape of TranslationKeys follows typeof english, so a typo in a key path is a compile error. Unit tests in tests/unit/i18n/keys.test.ts verify every derived key resolves to a string in both languages.

Usage

const { t } = useTranslation();

// Simple
<Button>{t(TranslationKeys.shared.button.save)}</Button>

// With interpolation
t(TranslationKeys.validation.maxLength, { max: 200 })
// "Maximum length is {{max}} characters" → "Maximum length is 200 characters"

// Aria labels (mandatory for accessibility)
<IconButton aria-label={t(TranslationKeys.shared.aria.close)}>
  <CloseIcon />
</IconButton>

Adding New Keys

  1. Add translation to english.ts
  2. Add translation to czech.ts (the satisfies check fails until you do)
  3. Use with t(TranslationKeys.x.y) — the key is available automatically

Aria Labels

Every interactive element needs an aria-label using i18n:

// Add to english.ts (and czech.ts)
aria: {
  deleteMarker: "Delete marker",
}

// Use in component
<IconButton aria-label={t(TranslationKeys.shared.aria.deleteMarker)}>
  <DeleteIcon />
</IconButton>
  • Forms — Validation messages