Right, so you’ve decided you don’t want your app to greet your German users with a friendly, debug-mode-style "homepage.header.welcome_message" instead of an actual “Willkommen”. We’ve all been there. The classic i18n (internationalization) problem is that your translation files are just loose JSON objects living out in the wild, and your code is just making a hopeful, stringly-typed request for a key that might not exist.

TypeScript’s entire reason for being is to catch this exact class of error at compile time, not in your user’s console. So let’s use it.

The Naïve Approach and Why It Fails

You start simple. A dictionary object and a t function. This is the “please don’t break in production” prayer method.

// translations/en.json
{
  "homepage": {
    "title": "My App",
    "header": {
      "welcome": "Welcome, human!"
    }
  }
}
// i18n.ts
import enTranslations from './translations/en.json';

export function t(key: string): string {
  // Some logic to traverse the object... magic happens here?
  return key;
}

// app.ts
t('homepage.header.welcome'); // 👍 "Welcome, human!"
t('homepage.header.logout'); // 👎 Runtime error? Nope. Just returns the key. useless.

This is garbage. We’ve gained nothing. TypeScript is utterly useless here because the key is just a string. Any string. t('i.am.become.error') is perfectly valid to the compiler, which is a travesty.

Leveraging typeof and Template Literal Types

This is where we get clever. We’re not going to redefine our entire translation structure by hand. That’s for chumps. We’re going to make TypeScript infer it from the JSON file itself and then generate all possible valid key paths.

Step one: grab the type of our master translation object.

// i18n.ts
import enTranslations from './translations/en.json';

// This is the shape of our data!
type Translations = typeof enTranslations;

// app.ts
// We can now use this type elsewhere
const sample: Translations['homepage']['title'] = 'My App'; // type: string

Cool, but we’re still accessing it with bracket notation. We need a type for the paths.

Creating a Path Generator Type

This looks like magic, but it’s just a recursive type that unions all possible paths. It’s the brains of the operation.

// Define a type that recursively generates all possible dotted paths for an object
type Paths<T> = {
  [K in keyof T]: T[K] extends Record<string, unknown>
    ? `${K & string}.${Paths<T[K]> & string}`
    : K;
}[keyof T];

// Now create the specific type for our translations
type TranslationKey = Paths<Translations>;

// Let's see what it resolves to!
// type TranslationKey = "homepage" | "homepage.title" | "homepage.header" | "homepage.header.welcome"

Boom. Now TranslationKey is a union type of every valid path string in our translation object. Our t function is no longer accepting any old string.

export function t(key: TranslationKey): string {
  // ... implementation still needs to resolve the key to a value
  return key;
}

// app.ts
t('homepage.header.welcome'); // 👍 Compiles successfully.
t('homepage.header.logout'); // 🚫 Compiler Error: Argument of type '"homepage.header.logout"'
                            // is not assignable to parameter of type 'TranslationKey'.

We’ve just moved a class of runtime error to a compile-time error. This is a massive win.

The Implementation: Actually Getting the Value

Here’s the dirty secret: actually writing the function that takes a dotted path and retrieves the value from a nested object is a runtime job. TypeScript can’t help us there. We have to do it the old-fashioned way, but we can now do it with confidence that the key is valid.

export function t(key: TranslationKey): string {
  const pathParts = key.split('.');
  let value: any = enTranslations; // Start at the root

  for (const part of pathParts) {
    if (value[part] === undefined) {
      // This should theoretically never happen if our type is correct.
      // But runtime is a wild place. Fallback gracefully.
      console.warn(`Translation key not found: ${key}`);
      return key; // Fallback to the key itself
    }
    value = value[part];
  }

  return value as string;
}

Handling Dynamic Keys and Interpolation

I know what you’re thinking: “But my translations have dynamic bits! Like 'user.greeting' that needs a username!”

You’re right. The purist approach above would reject t('user.greeting') if it’s not a literal key. The solution is to separate the static part of the key from the dynamic data. Your translation file should have a template, not a final string.

// translations/en.json
{
  "user": {
    "greeting": "Hello, {username}!"
  }
}

Your t function now needs a new signature for these cases.

// First, a type to find all keys whose value is a string containing `{...}`
// This is advanced but illustrates the power.
type KeysWithParams<T, P extends string> = {
  [K in TranslationKey]: T[K] extends string
    ? P extends '' // placeholder for more complex logic
      ? never
      : K
    : never;
}[TranslationKey];

// A more practical, albeit less perfect, approach is to overload the function.
function t(key: TranslationKey): string;
function t(key: TranslationKey, params: Record<string, string>): string;
function t(key: TranslationKey, params?: Record<string, string>): string {
  // ... get the raw string template first
  const template = getRawString(key);
  // ... then replace {key} with values from params object
  return replaceParams(template, params);
}

// Usage
t('user.greeting', { username: 'Alice' }); // "Hello, Alice!"

This keeps your static keys fully type-safe while acknowledging the runtime nature of the interpolation itself.

The takeaway? Don’t just use strings. Use the type system. It’s literally begging you to let it help. This pattern turns a common source of subtle bugs into a loud, obnoxious, and wonderfully helpful compiler error long before your code ever ships. And that’s the point.