
One small helper that turns suffix mess into typed clarity
{
"id": 42,
"name": "Deluxe Coffee Machine",
"nameDe": null,
"nameEn": null,
"nameFr": "Cafetière De Luxe",
"nameNl": null,
"description": "Brews the best coffee.",
"descriptionDe": null,
"descriptionEn": null,
"descriptionFr": "Prépare le meilleur café.",
"descriptionNl": null,
"price": 129.99
}
I couldn’t renegotiate the contract. Keys were flat with locale suffixes (nameFr
, descriptionDe
, etc.). The challenge:
- Robustness: dozens of pages and components reuse these records; anything fragile propagates everywhere.
- Readability: the team must be able to understand the solution at a glance.
- Reusability: one helper, no copy-pasting string‑concatenation logic or a separate implementation in every component.
- Type safety: catch typos and unsupported locales at compile time, not in prod.
Parse the Keys at the Type Level
Instead of writing imperative string‑concat in every component, I leaned on TypeScript’s template literal types:
type Locale = "en" | "fr" | "de" | "nl";
type BaseKeys<O> = {
[K in keyof O & string]: K extends `${infer B}${Capitalize<Locale>}`
? B
: never;
}[keyof O & string];
BaseKeys
scans keys and infers plain names ('name' | 'description'
). Now every part of the app can reference that union and ensure it’s valid.
Why this matters for the team
- Self-documenting: hover reveals the exact translatable fields.
- Typos blocked:
product['descriptonFr']
fails to compile. - Locale guardrails: add
'es'
toLocale
once, TypeScript wires the rest.
One Helper, Everywhere.
/**
* Capitalize the first letter of the string.
* Pre-compute the suffix to avoid re-computing it for each key.
* @param {T} s - The string to capitalize.
* @returns {Capitalize<T>} - The capitalized string.
*/
function capitalize<T extends string>(s: T): Capitalize<T> {
return (s.charAt(0).toUpperCase() + s.slice(1)) as Capitalize<T>;
}
/**
* Get the localized fields for the given object.
* Pass the exact fields you want to get, and it will return the localized fields for the given locale.
*
* @param {Locale} locale - The locale to get the localized fields for
* @param {Record<string, unknown>} obj - The object to get the localized fields from
* @param {ReadonlyArray<BaseKeys<Source>>} fields - The fields to get the localized fields for
*
* @returns {Record<FieldList[number], string | undefined>} The localized fields
* @example
* ```ts
* const obj = {
* nameEn: 'Name',
* nameDe: 'Name',
* nameNl: 'Naam',
* nameFr: 'Nom',
* descriptionEn: 'Description',
* descriptionDe: 'Beschreibung',
* descriptionNl: 'Beschrijving',
* descriptionFr: 'Description',
* };
*
* const fields = ['name', 'description'] as const;
*
* const localizedFields = getLocalized(locale, obj, fields);
*
* console.log(localizedFields);
* // { name: 'Name', description: 'Description' }
* ```
*/
export function getLocalized<
Source extends Record<string, unknown>,
Lang extends Locale,
FieldList extends ReadonlyArray<BaseKeys<Source>>
>(
locale: Lang,
obj: Source,
fields: FieldList
): { [K in FieldList[number]]: string | undefined } {
const suffix = capitalize(locale);
return fields.reduce((acc, base) => {
const key = `${base}${suffix}` as keyof Source;
const raw = base as keyof Source;
/**
* There are cases where the value is null, so we try the raw key
* example case:
{
name: 'Name',
nameDe: null,
nameNl: null,
nameFr: null,
}
*/
const val = obj[key] ?? obj[raw];
acc[base] = typeof val === "string" ? val : undefined;
return acc;
}, {} as Record<FieldList[number], string | undefined>);
}
const test = getLocalized(
"nl",
{
name: "Name",
nameDe: "Name",
nameNl: "Naam",
nameFr: null,
description: "Description",
descriptionDe: null,
},
["name", "description"]
);
console.log(test); // { name: 'Naam', description: 'Description'}
- Reusability: any component calls
getLocalized(userLocale, data, ['name','description'])
. - Graceful fallback: missing
nameFr
falls back toname
. - Literal return type: pass
['name','description'] as const
and the result is exactly{ name: string; description: string } | undefined
.
Team adoption tips
- Add a wrapper hook for React:
useLocalized(product, ['name','description'])
. - Centralize
Locale
in alocale.ts
so new languages propagate instantly. - Document the naming convention in the
README
and let ESLint flag keys not matching/^[a-z]+(En|Fr|De|Nl)$/
.
Real-World Wins
Conclusion
When you can’t control the upstream data shape, lean on the type system instead of duct-taping strings. BaseKeys
plus getLocalized
turned a messy API into a predictable, team-friendly pattern we reuse across the codebase. The solution is readable, minimal, and most importantly catches mistakes before they ship.
Enjoyed this piece?
If this piece was helpful or resonated with you, you can support my work by buying me a Coffee!

Become a subscriber receive the latest updates in your inbox.
Member discussion