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.
The standard approach and its failure mode
With key-based libraries like react-i18next, you write something like this:
const { t } = useTranslation();
return <Heading>{t("welcome.message")}</Heading>;
Then in your JSON files:
// en.json
{ "welcome": { "message": "Welcome back!" } }
// es.json
{ "welcome": {} } // oops - forgot this one
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.
The core idea: translations exist in your components (as code)
Instead of using string keys that point to external files, react-scoped-i18n passes translations as plain object literals directly to the t() function:
const { t } = useI18n();
return <Heading>
{t({
en: `Welcome back, ${name}!`,
es: `¡Bienvenido de nuevo, ${name}!`,
})}
</Heading>;
This is just a function call. The argument is an object. TypeScript can fully type-check it.
How the type enforcement works
During setup, you declare the languages your app supports:
// i18n/index.ts
import { createI18n } from "react-scoped-i18n";
export const { useI18n, I18nProvider } = createI18n({
languages: ["en", "es", "sl"],
defaultLanguage: "en",
});
Internally, createI18n generates a type from that languages array:
type Language = "en" | "es" | "sl";
type Translations = Record<Language, string>;
The t() function expects a Translations object - meaning every key in Language must be present. Leave one out and TypeScript errors immediately:
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>;
No codegen. No build step. No plugin. The type constraint flows directly from your configuration.
The same guarantee covers pluralization
Pluralization is where most i18n libraries get messy. react-scoped-i18n uses tPlural(), which enforces the same per-language completeness:
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>;
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.
What you give up
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.
The tradeoff in one sentence
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
Top comments (0)