Most i18n setups follow the same pattern: a pile of JSON files, a key like "common.button.submit", a lookup function, and a build step that merges it all together. That works at scale. For smaller projects it’s a lot of infrastructure for what is often just two languages.
A lighter approach: keep translations right next to the code that uses them, with a single generic helper to resolve the right value.
type TranslationValue<Arguments extends any[]> =
| string
| ((...args: Arguments) => string)
// EN is always required, other languages are optional
type TranslationMap<Lang extends string, Arguments extends any[]> = {
EN: TranslationValue<Arguments>
} & Partial<Record<Exclude<Lang, 'EN'>, TranslationValue<Arguments>>>
export const translate = <
Lang extends string,
Arguments extends any[]
>(
lang: Lang,
values: TranslationMap<Lang, Arguments>,
...args: Arguments
): string => {
const normalizedLang = lang.toUpperCase()
const valuesRecord = values as Record<string, TranslationValue<Arguments>>
const value = valuesRecord[normalizedLang] ?? valuesRecord.EN
if (typeof value === 'function') {
return (value as (...args: Arguments) => string)(...args)
}
return value
}EN is required in every translation map. Other languages are optional — if a translation is missing, it falls back to English automatically.
Usage
Static strings:
const label = translate(userLang, {
EN: 'Submit',
RU: 'Отправить',
DE: 'Absenden',
})Dynamic strings with arguments:
const message = translate(
userLang,
{
EN: (name: string) => `Welcome, ${name}!`,
RU: (name: string) => `Добро пожаловать, ${name}!`,
},
'Alice',
)TypeScript infers the argument types from the function signatures, so passing the wrong type or the wrong number of arguments is a compile error.
The problems with dictionaries
Dictionary-based i18n feels organized until the project grows. Then the cracks show.
Adding a string means touching two files: the component and every translation file. It’s easy to add the English key and forget to add it in one of the other languages — and since the app usually falls back silently, nobody notices until a user reports seeing English text in a German UI.
Deleting a string is worse. You remove the component, but the keys stay in every dictionary file forever. After a year the translation files are full of dead keys nobody can safely remove because nobody knows what still references what.
Searching by key is painful with nested objects. A key like common.form.validation.required means opening the dictionary, expanding three levels, and hoping the nesting matches your mental model. Rename a parent node and the string key silently breaks at runtime.
Type-safe key autocomplete is the hardest problem. To get TypeScript to suggest valid key paths and catch typos at compile time, you need types like these:
type Primitive = string | number | symbol
type GenericObject = Record<Primitive, unknown>
type Join<L extends Primitive | undefined, R extends Primitive | undefined> =
L extends string | number
? R extends string | number
? `${L}.${R}`
: L
: R extends string | number
? R
: undefined
type Union<L extends unknown | undefined, R extends unknown | undefined> =
L extends undefined
? R extends undefined ? undefined : R
: R extends undefined ? L : L | R
type NestedPaths<
T extends GenericObject,
Previous extends Primitive | undefined = undefined,
Path extends Primitive | undefined = undefined
> = {
[K in keyof T]: T[K] extends GenericObject
? NestedPaths<T[K], Union<Previous, Path>, Join<Path, K>>
: Union<Union<Previous, Path>, Join<Path, K>>
}[keyof T]
type TypeFromPath<T extends GenericObject, Path extends string> = {
[K in Path]: K extends keyof T
? T[K]
: K extends `${infer P}.${infer S}`
? T[P] extends GenericObject
? TypeFromPath<T[P], S>
: never
: never
}[Path]That’s a lot of type-level machinery to solve a problem that co-located translations don’t have in the first place. This approach is well documented, but it’s complexity you own and maintain going forward.
Why co-location works here
The translation lives where it’s used, gets deleted when the component is deleted, and shows up in the same diff. The whole class of “orphaned key” bugs doesn’t exist.
The trade-off is real though: you can’t generate a list of all strings in the app, which rules this out for anything that needs external translation workflows or audits. For a project that’s just two languages and one team, that’s usually fine.