We use cookies for analytics and advertising. Ads are disabled until you accept advertising cookies. Read our Cookie Policy and Privacy Policy.
Advanced TypeScript Mapping: Type Dynamic API Responses Without any | TVerge Tech
Advanced TypeScript Mapping: Type Dynamic API Responses Without any
Learn how mapped types, key remapping, and recursive conditional types let you safely type dynamic, inconsistently-cased API responses in TypeScript — no any required.
Advanced TypeScript Mapping: Safely Typing Dynamic API Responses Without any
Every API client eventually hits the same wall: the response shape isn't known until runtime, so the temptation is to slap any on it and move on. That works right up until a renamed field or a null-where-you-expected-a-string ships to production undetected.
Mapped types solve this without runtime overhead. Instead of describing what a type is, they describe how to transform one type into another — which is exactly what an API boundary needs: raw JSON in, a safe, narrowed shape out.
The core idea: transform, don't guess
A mapped type iterates over the keys of an existing type and rebuilds it:
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
type Partial<T> = {
[K in keyof T]?: T[K];
};
Both ship in TypeScript's standard library, but the real power shows up when you combine mapped types with conditional types, key remapping (as), and template literal types to model something an API actually returns.
Step 1 — Model the response as a discriminated union
Don't type the payload. Type the state of the request:
type ApiResult<T> =
| { status: "success"; data: T }
| { status: "error"; error: { code: string; message: string } }
| { status: "loading" };
function handle<T>(result: ApiResult<T>) {
if (result.status === "success") return result.data; // T — narrowed
if (result.status === "error") return console.error(result.error.message);
return null; // loading
}
// Angle A — paginated list state (infinite scroll / "load more")
type ListResult<T> =
| { status: "idle" }
| { status: "loading"; page: number }
| { status: "success"; items: T[]; nextCursor: string | null }
| { status: "error"; error: string };
// Angle B — form submission result with field-level validation errors
type SubmitResult<T> =
| { status: "success"; data: T }
| { status: "invalid"; fieldErrors: Partial<Record<keyof T, string>> }
| { status: "error"; error: string };
function showErrors<T>(result: SubmitResult<T>) {
if (result.status === "invalid") {
for (const key in result.fieldErrors) {
console.warn(`${key}: ${result.fieldErrors[key]}`);
}
}
}
Partial<Record<keyof T, string>> is itself a mapped type nested inside another — it maps every key of your form's data type to an optional string, so field errors stay in sync with the form shape automatically.
Step 2 — Normalize inconsistent API casing with key remapping
Real-world APIs mix snake_case, camelCase, and inconsistent nesting. Key remapping (TS 4.1+) transforms the keys themselves inside a mapped type using as:
type SnakeToCamel<S extends string> =
S extends `${infer Head}_${infer Tail}`
? `${Head}${Capitalize<SnakeToCamel<Tail>>}`
: S;
type CamelizeKeys<T> = {
[K in keyof T as K extends string ? SnakeToCamel<K> : K]: T[K];
};
interface RawUser {
user_id: number;
first_name: string;
last_login_at: string | null;
}
type User = CamelizeKeys<RawUser>;
// { userId: number; firstName: string; lastLoginAt: string | null }
More angles on key remapping — it's not just renaming:
// Angle A — drop internal/private fields the API leaks but the UI shouldn't see
type PublicOnly<T> = {
[K in keyof T as K extends `_${string}` ? never : K]: T[K];
};
interface RawProduct {
id: string;
name: string;
_internal_score: number;
_debug_flag: boolean;
}
type Product = PublicOnly<RawProduct>;
// { id: string; name: string } — underscored keys removed entirely
// Angle B — filter keys by their VALUE type, not their name
type StringFieldsOnly<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
type SearchableFields = StringFieldsOnly<RawUser>;
// { first_name: string } — user_id (number) and last_login_at (string | null) drop out
// Angle C — namespace/prefix keys for a legacy adapter layer
type PrefixWith<T, P extends string> = {
[K in keyof T as K extends string ? `${P}${Capitalize<K>}` : K]: T[K];
};
type LegacyUser = PrefixWith<User, "legacy">;
// { legacyUserId: number; legacyFirstName: string; legacyLastLoginAt: string | null }
Returning never from the as clause is the mechanism Pick/Omit use internally — it's a general-purpose filter, not just a casing tool.
Step 3 — Deep-normalize nested and array responses
A single-level mapped type won't reach nested objects or arrays — recursion handles that:
type DeepCamelize<T> = T extends readonly (infer U)[]
? DeepCamelize<U>[]
: T extends Date
? T
: T extends object
? { [K in keyof T as K extends string ? SnakeToCamel<K> : K]: DeepCamelize<T[K]> }
: T;
interface RawOrder {
order_id: string;
line_items: { product_id: string; unit_price: number }[];
shipping_address: { postal_code: string };
}
type Order = DeepCamelize<RawOrder>;
More angles on recursive mapped types:
// Angle A — DeepReadonly: freeze a normalized response before caching it
type DeepReadonly<T> = T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
type CachedOrder = DeepReadonly<Order>; // every nested property, at every depth, is readonly
// Angle B — DeepPartial: type a PATCH request body from a full resource type
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
function patchOrder(id: string, updates: DeepPartial<Order>) {
// caller can pass { shippingAddress: { postalCode: "94103" } } and nothing else
}
// Angle C — DeepNullable: model a GraphQL-style response where any field can be null
type DeepNullable<T> = T extends object
? { [K in keyof T]: DeepNullable<T[K]> | null }
: T | null;
Each of these reuses the exact same recursion shape as DeepCamelize — swap what happens at the base case (add readonly, add ?, add | null) and you get a different guarantee for free.
Step 4 — Tie it to fetch with a generic wrapper
async function apiFetch<T>(url: string): Promise<ApiResult<DeepCamelize<T>>> {
try {
const res = await fetch(url);
if (!res.ok) {
return { status: "error", error: { code: String(res.status), message: res.statusText } };
}
const raw = (await res.json()) as T;
return { status: "success", data: camelizeDeep(raw) as DeepCamelize<T> };
} catch (err) {
return { status: "error", error: { code: "NETWORK", message: (err as Error).message } };
}
}
const result = await apiFetch<RawOrder>("/api/orders/123");
if (result.status === "success") {
console.log(result.data.shippingAddress.postalCode);
}
More angles on the fetch wrapper:
// Angle A — a typed paginated fetcher built on the ListResult union from Step 1
async function fetchPage<T>(url: string, page: number): Promise<ListResult<DeepCamelize<T>>> {
const res = await fetch(`${url}?page=${page}`);
if (!res.ok) return { status: "error", error: res.statusText };
const { items, next_cursor } = (await res.json()) as { items: T[]; next_cursor: string | null };
return { status: "success", items: items.map((i) => camelizeDeep(i)) as DeepCamelize<T>[], nextCursor: next_cursor };
}
// Angle B — a React hook wrapping apiFetch, still fully generic
function useApiResource<T>(url: string) {
const [result, setResult] = useState<ApiResult<DeepCamelize<T>>>({ status: "loading" });
useEffect(() => {
apiFetch<T>(url).then(setResult);
}, [url]);
return result; // consumer gets the same narrowed union as the raw function
}
// Angle C — validating the runtime boundary instead of trusting `as T`
import { z } from "zod";
async function apiFetchValidated<S extends z.ZodTypeAny>(
url: string,
schema: S
): Promise<ApiResult<DeepCamelize<z.infer<S>>>> {
const res = await fetch(url);
const parsed = schema.safeParse(await res.json());
if (!parsed.success) {
return { status: "error", error: { code: "SCHEMA", message: parsed.error.message } };
}
return { status: "success", data: camelizeDeep(parsed.data) as DeepCamelize<z.infer<S>> };
}
Angle C replaces the one unsafe as T assertion with a real runtime check — z.infer<S> derives the TypeScript type directly from the Zod schema, so the compile-time type and the runtime validator can never drift apart.
Common pitfalls
Mapping any instead of a concrete interface.CamelizeKeys<any> resolves to any — anchor mapped types to a real interface or a schema-inferred type.
Forgetting as const on discriminants. If status: "success" widens to string upstream, narrowing breaks silently.
Recursing into Date or class instances. Add an explicit T extends Date ? T : branch before the generic object check, as shown in Step 3.