47.1 Typing Locale Strings and Translation Keys
Right, let’s talk about something that starts with the best intentions and often ends in a tangled mess of stringly-typed despair: managing translation keys. You’ve decided to not hardcode every user-facing string, which is fantastic. But if you’re just doing t('some_key') all over your codebase, you’ve traded one problem for another. You now have a codebase littered with invisible dependencies on a JSON file you need to pray you spelled correctly. TypeScript exists precisely to save us from this particular flavor of self-inflicted pain.
The goal is simple: make the compiler yell at us if we try to use a translation key that doesn’t exist. It’s like having a spellchecker for your i18n config. The naive approach is to just define a type as a union of string literals.
type TranslationKey = 'home.title' | 'home.subtitle' | 'user.login_error';
This works for a tiny app. But let’s be honest, your app isn’t tiny, and manually updating this type every time you add a new key is a recipe for frustration and eventual failure. We need to generate this type.
Generating Types from Your Translation Files
This is where the real magic happens. The concept is to structure your translation files (typically JSON) in a way that allows TypeScript to infer the entire nested structure of keys. We don’t just want 'home.title'; we want to represent the whole hierarchy. This is a job for recursive types and typeof.
First, create a master file for your default locale, say en.json. Structure it with nested objects. This isn’t just good for typing; it’s good for organization.
// locales/en.json
{
"home": {
"title": "Welcome to my app",
"subtitle": "We're glad you're here"
},
"user": {
"login_error": "Invalid login credentials",
"logout": "Logout"
}
}
Now, in your TypeScript code, we import this JSON file and use typeof to create a type that mirrors its structure exactly.
import enTranslations from './locales/en.json';
type PathsToStringProps<T> = T extends string ? {} : {
[K in keyof T]: `${K & string}${'' | '.'}${PathsToStringProps<T[K]>}`
}[keyof T];
type TranslationKey = PathsToStringProps<typeof enTranslations>;
// This results in a type equivalent to:
// type TranslationKey = "home" | "home.title" | "home.subtitle" | "user" | "user.login_error" | "user.logout"
Wait, come back! I know that PathsToStringProps type looks like something you’d find scrawled in a haunted cathedral, but let’s break it down. It’s a recursive type that iterates through each property (K in keyof T). If the property’s value is a string, it just returns the key itself. If it’s another object, it tacks a dot onto the key and then recursively appends the results from the nested object. The [keyof T] at the end is a mapped type trick to get a union of all the resulting string literal types.
Now, your t function can be properly typed:
function t(key: TranslationKey): string {
// ... your implementation to fetch the actual string
}
t('home.title'); // Works perfectly
t('home.nonexistent'); // Compiler Error: Property 'nonexistent' does not exist on type...
The Interpolation Problem: More Than Just Keys
You didn’t think it would be that easy, did you? Of course not. Your translations need interpolation: Hello, {{userName}}!. So now our function needs to not only validate the key but also require the correct variables. Our simple t(key): string signature is dead.
We need a type that can, given a key, tell us what parameters (if any) are required. This requires a more advanced form of type generation. We need to create an object type that represents our translations with their intended parameters.
We can’t easily do this with raw JSON, as it can’t express function signatures. This is where moving to TypeScript files (.ts) for your translations can be a powerful upgrade.
// locales/en.ts
export default {
home: {
title: "Welcome",
subtitle: "Hello, {{userName}}!"
}
} as const;
Now, we can create a type that digs into the value of each key, checks if it contains interpolation placeholders, and derives an object of required parameters.
type GetParams<T> = T extends `{{${infer Param}}}${infer Rest}`
? { [K in Param | keyof GetParams<Rest>]: string }
: T extends `${string}{{${infer Param}}}${infer Rest}`
? { [K in Param | keyof GetParams<Rest>]: string }
: {};
// Then, use it to define our t function overloads
type Translate = {
<K extends TranslationKey>(key: K, params?: GetParams<typeof enTranslations[K]>): string;
}
const t: Translate = (key: string, params?: object) => { /* impl */ };
t('home.title'); // OK, no params needed
t('home.subtitle', { userName: 'Alice' }); // OK
t('home.subtitle'); // Compiler Error: Property 'userName' is missing
t('home.subtitle', { age: 30 }); // Compiler Error: 'age' does not exist
This is the gold standard. It requires more setup, often with a build step to convert JSON to TS, but the payoff is immense: end-to-end type safety from your translation file to your component.
The Pitfalls: Dynamic Keys and the as Escape Hatch
Sometimes, you have to break the system. You might be loading keys dynamically from a server or building them programmatically. TypeScript will rightly flag this as an error.
const dynamicKey = `user.${someVariable}` as const;
t(dynamicKey); // Error!
In these cases, you have to be deliberate. Use a type assertion to tell the compiler you know what you’re doing, but also add a runtime check if possible. This is a conscious decision to step outside the safety net.
// Use a type guard to check at runtime if you can
const isValidKey = (key: string): key is TranslationKey => {
return /* logic to check against your known keys */;
};
if (isValidKey(dynamicKey)) {
t(dynamicKey); // Safe inside this block
} else {
// Handle the error
}
// Or, if you're absolutely sure, assert (use sparingly!)
t(dynamicKey as TranslationKey);
The beauty of this entire approach is that it turns your translation files into a rigorous, compiler-checked schema. Adding a new string isn’t just a task for a translator; it’s a task for a developer who must now define its type contract. This enforced discipline is what separates a maintainable i18n system from a ticking time bomb of broken UI.