Right, let’s talk about making your app speak human. Not just a human, but many humans, in their own languages and formats. And we’re going to do it without resorting to a brittle mess of string keys and hope. We’re using TypeScript, for heaven’s sake. We have a type system; let’s make it earn its keep.

The core problem with most i18n setups is that your translation files are a black box. You have a key like homepage.greeting and you just… hope that the value for that key exists and has the right number of placeholders. It’s like sending a junior developer to a meeting with a sticky note that just says “ask about the thing.” It’s a strategy, but not a good one.

The Goal: Total Type-Safety

We’re not just talking about typing the key. Any half-baked wrapper can do t(key: string). We’re talking about typing the entire translation structure. We want the compiler to scream at us if we try to use a key that doesn’t exist, forget a placeholder, or pass a number where a date is expected. This is the difference between finding a bug at 3 PM during development and at 3 AM from a user in a timezone you’ve never heard of.

Enter next-intl (The New Hotness)

If you’re in the Next.js ecosystem, next-intl is arguably the most elegant solution right now. It’s built by the same folks who maintain format.js (React Intl), so they know a thing or two about i18n. Its magic trick is leveraging Next.js’s built-in support for async components and routing to load your translations at the component level, seamlessly.

First, you define your translation structure in a JSON file. But watch this—we’re going to use TypeScript to define its type.

// messages/en.json
{
  "HomePage": {
    "title": "Hello, world!",
    "welcome": "Welcome back, {name}! You have {count, number} new messages."
  }
}

Now, the crucial part: we create a type that represents the entire structure of our messages.

// messages.ts
import en from './en.json';

// This is the genius bit. We infer the type from our default JSON file.
// Now, every other locale's JSON must match this structure.
export type Messages = typeof en;

// Optional: Declare a union type of all available locales
export type Locale = 'en' | 'de' | 'fr';

You then set up the routing and the provider, which Next.js docs cover well. The beauty is in the hook you get in your components:

import {useTranslations} from 'next-intl';
import {type Messages} from './messages';

// This t function now KNOWS the entire structure of your messages.
const t = useTranslations<Messages>('HomePage');

// This is perfectly valid and fully typed. Hover over `title`—it's a string.
const title = t('title');

// This is also valid. Hover over `welcome`—it's a function that REQUIRES
// an object with `name: string` and `count: number`.
const welcomeMessage = t('welcome', {name: 'Alice', count: 5});

// This would cause a compile-time error. Glorious.
const error1 = t('nonexistentKey'); // Error: Argument of type ... is not assignable...
const error2 = t('welcome', {name: 'Alice'}); // Error: Property 'count' is missing...

This is i18n nirvana. The compiler is now your dedicated translation reviewer.

The Battle-Tested i18next with Types

i18next is the industry workhorse. It’s framework-agnostic, incredibly powerful, and has plugins for everything short of making a decent cup of coffee. Typing it is slightly more manual but just as effective.

The common approach is to define your resources as a constant with as const and then use that to generate your types. This is more “bring your own types” than next-intl’s inference.

// locales/index.ts
export const resources = {
  en: {
    translation: {
      HomePage: {
        title: 'Hello, world!',
        welcome: 'Welcome back, {{name}}! You have {{count}} new messages.'
      }
    }
  }
} as const;

// Now we dive into the type system to extract our keys.
// This looks gnarly, but you set it up once and forget it.
type DefaultLocale = typeof resources['en']['translation'];
type RecursiveKeyOf<TObj extends object> = {
  [TKey in keyof TObj & (string | number)]: TObj[TKey] extends object
    ? `${TKey}.${RecursiveKeyOf<TObj[TKey]>}`
    : `${TKey}`;
}[keyof TObj & (string | number)];

// This gives us a union type: "HomePage" | "HomePage.title" | "HomePage.welcome"
export type I18nKey = RecursiveKeyOf<DefaultLocale>;

You then create a custom hook that wraps i18next’s t function with your rigorous type definition.

import {useTranslation} from 'react-i18next';
import {type I18nKey} from '../locales';

// A custom hook because the one from react-i18next is too permissive
export const useTypedTranslation = () => {
  const {t} = useTranslation();
  return {
    t: (key: I18nKey, options?: object) => t(key, options)
  };
};

// Usage in a component
const {t} = useTypedTranslation();
const title = t('HomePage.title'); // OK
const welcome = t('HomePage.welcome', {name: 'Alice', count: 5}); // OK
t('Some.nonsense.key'); // Compile Error: Type '"Some.nonsense.key"' is not assignable to type 'I18nKey'

Is the type extraction code a little scary? Yes. But you write it once, put it in a @types/ folder, and never think about it again while reaping the benefits forever.

The Pitfalls They Don’t Tell You About

  1. Pluralization is a Minefield: Both libraries handle plurals, but the rules are different per language (English has “one” and “other”, Arabic has six). Your types can ensure you provide the necessary values, but they can’t yet validate the logic inside your translation files. You still need human review for that. Sorry.
  2. Dynamic Keys are the Arch-Nemesis: Sometimes you have to generate a key dynamically, e.g., t(error.${errorCode}). This will blow a hole in your type safety. The best workaround is to use a type guard to narrow the possible keys. if (isValidI18nKey('error.' + errorCode)) { ... }. It’s not perfect, but it’s better than nothing.
  3. Keeping Locales in Sync: Your en.json is the source of truth. When you add a new key, you must add it to all other locale files. The type system can’t help you there. Use a script or a CI check that compares the keys across all files and fails the build if they’re out of sync. It’s the only way to avoid missing translations in production.

The bottom line? Typing your i18n isn’t just a nice-to-have; it’s a critical reliability feature. It transforms i18n from a frustrating game of whack-a-mole with missing keys into a robust, refactorable part of your codebase. Stop guessing and start letting the compiler do the boring work.