How I Made Missing Translations a Compile-Time TypeScript Error
Source: Dev.to
The Problem with Runtime‑Only Missing Translations
Most React i18n libraries catch missing translations only at runtime, so users see broken UI before developers notice.
Example with a key‑based library like react‑i18next:
const { t } = useTranslation();
return {t("welcome.message")};
// en.json
{
"welcome": {
"message": "Welcome back!"
}
}
// es.json
{
"welcome": {}
// oops – forgot this one
}
TypeScript cannot know that "welcome.message" is missing from es.json. Spanish users would see the raw key string, and the issue surfaces only when the language is switched. Some libraries offer code‑generation for typed key maps, but that adds a build step and only guarantees key existence, not the actual content.
A Compile‑Time Solution: react-scoped-i18n
Instead of using string keys that reference external files, react‑scoped‑i18n passes translations as plain object literals directly to the t() function:
const { t } = useI18n();
return (
{t({
en: `Welcome back, ${name}!`,
es: `¡Bienvenido de nuevo, ${name}!`,
})}
);
The argument is a regular object, so TypeScript can fully type‑check it.
Setting Up the Provider
Create the i18n configuration once:
// i18n/index.ts
import { createI18n } from "react-scoped-i18n";
export const { useI18n, I18nProvider } = createI18n({
languages: ["en", "es", "sl"],
defaultLanguage: "en",
});
createI18n generates types from the languages array:
type Language = "en" | "es" | "sl";
type Translations = Record;
The t() function expects a Translations object, meaning every language key must be present. Omitting one triggers a TypeScript error:
return (
{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'
})}
);
No code generation, build step, or plugin is required—the type constraint flows directly from your configuration.
Handling Pluralization
Plural forms are managed with tPlural(), which enforces the same per‑language completeness:
const { tPlural } = useI18n();
return (
{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 dual form
many: `Imaš ${count} jabolk.`,
},
})}
);
At the type level, all possible categories (negative, zero, one, two, many, etc.) are available for every language; you only define the forms you need. This accommodates languages with dual, six‑way, or other complex plural rules without hard‑coding.
Trade‑offs and When to Use This Approach
-
Pros
- Compile‑time guarantee that every supported language has a translation for each string.
- No external code generation or build‑time tooling.
- Immediate feedback while writing components.
-
Cons
- Inline translations don’t fit workflows that rely on external translation platforms (Crowdin, Lokalise, etc.).
- As the number of supported languages grows, component files become larger and noisier. Three to five languages feel comfortable; ten starts to feel heavy.
For small teams that maintain their own translations, the trade‑off is often worthwhile.
Try It Out
react-scoped-i18n is open source and available on GitHub: