Section 2 · Generics in Depth
Designing a Typed API Client
22 min4 min readCode playground
Lesson at a glance
Design a fetch client typed from a single route map, so each call infers its input and response types from the route name and callers never write a type argument or a cast.
- 1One source of truth: the route map
- 2The route name is the only inference site
- 3Unsafe inside, safe outside
- 4Growing the map
- 5What makes this design good
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
Every API client starts the same way: fetch(url).then((r) => r.json()), which returns Promise<any>, and a codebase slowly fills with as Order and as Order[] at call sites. Each cast is a small, unreviewed claim about the server. This lesson puts the whole section together to build a client where callers write no casts and no type arguments, and where a wrong route, a missing field or a mistyped id fails to compile.
One source of truth: the route map
Start by writing down every endpoint once, as a type:
type OrderStatus = 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'returned';
type Order = { id: number; status: OrderStatus; totalCents: number };
type CancelledOrder = { id: number; status: 'cancelled'; cancelReason: string };
type Page<T> = { items: T[]; nextCursor: string | null };
type Routes = {
'GET /orders/:id': { input: { id: number }; output: Order };
'GET /orders': { input: { status?: OrderStatus; cursor?: string }; output: Page<Order> };
'POST /orders/:id/cancel': { input: { id: number; reason: string }; output: CancelledOrder };
};Each key names an endpoint, method and path together. Each value says what the endpoint takes and what it returns. Nothing else in the client will mention Order or Page directly; it will all be derived from this table. Adding an endpoint means adding one line here.
The route name is the only inference site
Now the signature. You want the caller to write the route and the input, and get the right response type back:
async function call<R extends keyof Routes>(
route: R,
input: Routes[R]['input'],
): Promise<Routes[R]['output']> {
// implementation below
}
const order = await call('GET /orders/:id', { id: 1042 }); // Order
const page = await call('GET /orders', { status: 'shipped' }); // Page<Order>
await call('POST /orders/:id/cancel', { id: 1042 });
// Error: Argument of type '{ id: number; }' is not assignable to parameter
// of type '{ id: number; reason: string; }'.
// Property 'reason' is missing in type '{ id: number; }' but required in type …Every idea from this section is at work here. R appears three times, linking the route to the input and the output. Its constraint, keyof Routes, is a union of string literals, so the argument is inferred as the literal 'GET /orders/:id' rather than string. And R has exactly one inference site, route; input only uses R, through the indexed access Routes[R]['input']. TypeScript infers the route, then checks the input against what that route needs. No NoInfer required, because nothing else could contribute a candidate.
Unsafe inside, safe outside
Inside call, input has the type Routes[R]['input'] for some unknown R. TypeScript cannot know which fields it has, so building the URL needs a cast. That is fine, as long as it happens in exactly one place:
type Transport = (method: string, path: string, body: unknown) => Promise<unknown>;
function createClient(transport: Transport) {
return async function call<R extends keyof Routes>(
route: R,
input: Routes[R]['input'],
): Promise<Routes[R]['output']> {
const [method, template] = route.split(' ');
// The route map guarantees every :param has a matching input field.
const values = input as Record<string, unknown>;
const rest: Record<string, unknown> = { ...values };
const path = template.replace(/:(\w+)/g, (_, name: string) => {
delete rest[name];
return encodeURIComponent(String(values[name]));
});
const result = await transport(method, path, method === 'GET' ? undefined : rest);
return result as Routes[R]['output'];
};
}There are two casts, both commented, both in the one function every request goes through. Callers get a fully checked API, and a reviewer who wants to audit the unsafe parts reads ten lines. Compare that with a codebase where every one of two hundred call sites says as Order.
Injecting the transport is a design choice worth copying. The real app passes a function that calls fetch; tests pass a fake that records requests and returns canned data. The types do not care which.
Growing the map
Real clients have endpoints that take nothing, such as 'GET /me'. With this design you give them input: {} and callers write call('GET /me', {}). That empty object is a small wart, and it is a fair trade for now: the alternative is a signature that makes input optional only for some routes, which needs a conditional type. You will have that tool after Conditional Types, Distribution and infer, and the change will touch only the signature, not a single call site.
The same goes for query strings versus bodies, headers, or per-route error types. Each is one more property on the route-map values and one more lookup in the signature. The shape of the solution stays the same: describe it once in the table, derive it everywhere else.
What makes this design good
Look at what the caller never has to do: write <Order>, cast, import response types, or remember which endpoints need which fields. The best generic APIs feel like they have no types at all, because every type is inferred from something the caller had to write anyway (here, the route name).
In the exercise you will build this client against a fake transport. That ends the generics section. Section 3 takes apart the type operators you have been borrowing (keyof, indexed access) and adds mapped, conditional and template literal types, so you can derive even more from a single source of truth.
Key takeaways
- Put every endpoint’s input and output in one route-map type, and derive the client’s signatures from it.
- Make the route name the only inference site; everything else is looked up from it with indexed access.
- Keep unavoidable casts inside the implementation, in one place, so callers get a fully checked API.
- A typed client still trusts the server’s response; validate it at the boundary before relying on the declared output type.