DEV Community

Amer Kočan
Amer Kočan

Posted on

How I Made Missing Translations a Compile-Time TypeScript Error

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>;
Enter fullscreen mode Exit fullscreen mode

Then in your JSON files:

// en.json
{ "welcome": { "message": "Welcome back!" } }

// es.json
{ "welcome": {} }  // oops - forgot this one
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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",
});
Enter fullscreen mode Exit fullscreen mode

Internally, createI18n generates a type from that languages array:

type Language = "en" | "es" | "sl";
type Translations = Record<Language, string>;
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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)