3 min read

TypeScript Soup: Taming an Unruly API

One tiny helper, big payoff. Use template literal types to infer base fields from `nameFr` style keys, then `getLocalized(locale, obj, fields)` returns exactly what you asked for. No copy-paste, no string concat, typos blocked, locales centralized, fallbacks handled.
TypeScript Soup: Taming an Unruly API
Photo by Point Normal on Unsplash

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];

BaseKeysscans 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' to Locale 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 to name.
  • Literal return type: pass ['name','description'] as const and the result is exactly { name: string; description: string } | undefined.

Team adoption tips

  1. Add a wrapper hook for React: useLocalized(product, ['name','description']).
  2. Centralize Locale in a locale.ts so new languages propagate instantly.
  3. 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!

Click the image to visit Alvis’s Buy Me a Coffee page.
Subscribe to our newsletter.

Become a subscriber receive the latest updates in your inbox.