Tools
Tools: How I Made Missing Translations a Compile-Time TypeScript Error
2026-03-04
0 views
admin
The standard approach and its failure mode ## The core idea: translations exist in your components (as code) ## How the type enforcement works ## The same guarantee covers pluralization ## What you give up ## The tradeoff in one sentence Most React i18n libraries catch missing translations at runtime, which means users see broken UI before you do. I wanted TypeScript to catch them before the code ships. Here's how react-scoped-i18n does it. With key-based libraries like react-i18next, you write something like this: Then in your JSON files: TypeScript has no idea that "welcome.message" is missing from es.json. Your Spanish users get a raw key string rendered in the UI, and it only surfaces when someone actually switches the language. Some libraries offer codegen to produce typed key maps, but that's a build step you have to maintain, and the type safety only covers key existence - not the actual content. Instead of using string keys that point to external files, react-scoped-i18n passes translations as plain object literals directly to the t() function: This is just a function call. The argument is an object. TypeScript can fully type-check it. During setup, you declare the languages your app supports: Internally, createI18n generates a type from that languages array: The t() function expects a Translations object - meaning every key in Language must be present. Leave one out and TypeScript errors immediately: No codegen. No build step. No plugin. The type constraint flows directly from your configuration. Pluralization is where most i18n libraries get messy. react-scoped-i18n uses tPlural(), which enforces the same per-language completeness: At the type level, all categories (negative, zero, one, two, many) are available to every language - you only define what you need. So Slovenian dual forms, Arabic's six-way split, and Polish few all resolve correctly without any hardcoding. Exact number keys and negative are defined as they are used, on top of the spec. This approach works well when developers write and own the translations. If your workflow involves handing off to external translators via Crowdin, Lokalise, or similar platforms, colocated inline translations don't fit that pipeline. For that, you'd want a more standardised key-based library. It also becomes noisier as the number of supported languages grows. Three to five languages is comfortable. Ten starts to feel heavy in the component file. You trade the flexibility of external translation files for a guarantee that TypeScript enforces: if your app compiles, every supported language has every string. For small teams who maintain their own translations, that tradeoff is worth it. If you want to try it: react-scoped-i18n on GitHub Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse CODE_BLOCK:
const { t } = useTranslation(); return <Heading>{t("welcome.message")}</Heading>; Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
const { t } = useTranslation(); return <Heading>{t("welcome.message")}</Heading>; CODE_BLOCK:
const { t } = useTranslation(); return <Heading>{t("welcome.message")}</Heading>; CODE_BLOCK:
// en.json
{ "welcome": { "message": "Welcome back!" } } // es.json
{ "welcome": {} } // oops - forgot this one Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
// en.json
{ "welcome": { "message": "Welcome back!" } } // es.json
{ "welcome": {} } // oops - forgot this one CODE_BLOCK:
// en.json
{ "welcome": { "message": "Welcome back!" } } // es.json
{ "welcome": {} } // oops - forgot this one CODE_BLOCK:
const { t } = useI18n(); return <Heading> {t({ en: `Welcome back, ${name}!`, es: `¡Bienvenido de nuevo, ${name}!`, })}
</Heading>; Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
const { t } = useI18n(); return <Heading> {t({ en: `Welcome back, ${name}!`, es: `¡Bienvenido de nuevo, ${name}!`, })}
</Heading>; CODE_BLOCK:
const { t } = useI18n(); return <Heading> {t({ en: `Welcome back, ${name}!`, es: `¡Bienvenido de nuevo, ${name}!`, })}
</Heading>; CODE_BLOCK:
// i18n/index.ts
import { createI18n } from "react-scoped-i18n"; export const { useI18n, I18nProvider } = createI18n({ languages: ["en", "es", "sl"], defaultLanguage: "en",
}); Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
// i18n/index.ts
import { createI18n } from "react-scoped-i18n"; export const { useI18n, I18nProvider } = createI18n({ languages: ["en", "es", "sl"], defaultLanguage: "en",
}); CODE_BLOCK:
// i18n/index.ts
import { createI18n } from "react-scoped-i18n"; export const { useI18n, I18nProvider } = createI18n({ languages: ["en", "es", "sl"], defaultLanguage: "en",
}); CODE_BLOCK:
type Language = "en" | "es" | "sl";
type Translations = Record<Language, string>; Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
type Language = "en" | "es" | "sl";
type Translations = Record<Language, string>; CODE_BLOCK:
type Language = "en" | "es" | "sl";
type Translations = Record<Language, string>; CODE_BLOCK:
return <Heading> {t({ en: `Welcome back, ${name}!`, es: `¡Bienvenido de nuevo, ${name}!`, // TypeScript Error: Property 'sl' is missing in type // '{ en: string; es: string; }' // but required in type 'Translations' })}
</Heading>; Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
return <Heading> {t({ en: `Welcome back, ${name}!`, es: `¡Bienvenido de nuevo, ${name}!`, // TypeScript Error: Property 'sl' is missing in type // '{ en: string; es: string; }' // but required in type 'Translations' })}
</Heading>; CODE_BLOCK:
return <Heading> {t({ en: `Welcome back, ${name}!`, es: `¡Bienvenido de nuevo, ${name}!`, // TypeScript Error: Property 'sl' is missing in type // '{ en: string; es: string; }' // but required in type 'Translations' })}
</Heading>; CODE_BLOCK:
const { tPlural } = useI18n(); return <Text>{tPlural(count, { en: { one: `You have one apple.`, many: `You have ${count} apples.`, }, es: { one: `Tienes una manzana.`, many: `Tienes ${count} manzanas.`, }, sl: { one: `Imaš eno jabolko.`, two: `Imaš dve jabolki.`, // Slovenian has a dual form many: `Imaš ${count} jabolk.`, },
})}</Text>; Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
const { tPlural } = useI18n(); return <Text>{tPlural(count, { en: { one: `You have one apple.`, many: `You have ${count} apples.`, }, es: { one: `Tienes una manzana.`, many: `Tienes ${count} manzanas.`, }, sl: { one: `Imaš eno jabolko.`, two: `Imaš dve jabolki.`, // Slovenian has a dual form many: `Imaš ${count} jabolk.`, },
})}</Text>; CODE_BLOCK:
const { tPlural } = useI18n(); return <Text>{tPlural(count, { en: { one: `You have one apple.`, many: `You have ${count} apples.`, }, es: { one: `Tienes una manzana.`, many: `Tienes ${count} manzanas.`, }, sl: { one: `Imaš eno jabolko.`, two: `Imaš dve jabolki.`, // Slovenian has a dual form many: `Imaš ${count} jabolk.`, },
})}</Text>;
how-totutorialguidedev.toaiswitchgitgithub