EduFlow

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.

  1. 1One source of truth: the route map
  2. 2The route name is the only inference site
  3. 3Unsafe inside, safe outside
  4. 4Growing the map
  5. 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:

TypeScript
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:

TypeScript
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.

The call site passes a route string and an input object. The route string is inferred as the literal key R. R indexes the Routes map, which yields the input type used to check the input argument and the output type used for the returned promise. 'GET /orders/:id' { id: 1042 } R Routes[R] ['input'] checks ['output'] Promise<Order>
The route key is inferred once; input and output are looked up from it

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:

TypeScript
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.

Further reading

Check your understanding

4 questions

Question 1 of 4

Given call<R extends keyof Routes>(route: R, input: Routes[R]['input']): Promise<Routes[R]['output']>, how is R determined for call('GET /orders/:id', { id: 1042 })?

Question 2 of 4

The implementation of call needs to read path parameters from input, whose type is Routes[R]['input']. What is the most reasonable approach?

Question 3 of 4

Your client declares 'GET /orders/:id' as returning Order. The server ships a change that renames status to state. What does TypeScript do?

Question 4 of 4

Why is it better to type the route as one string like 'POST /orders/:id/cancel' instead of passing method and path as two separate arguments?

Practice

Code playground

Type the checkout client from a route map

Build Cartwheel’s typed call function.

  1. Add the route 'POST /orders/:id/cancel' to Routes: its input is { id: number; reason: string } and its output is CancelledOrder.
  2. Make call generic over the route name, so its input and its resolved value are looked up from Routes. The type tests show what callers expect: no type arguments, no casts.
  3. At runtime, replace each :param in the path with the matching input value, and send the remaining fields as the body for non-GET requests. call('POST /orders/:id/cancel', { id: 7, reason: 'Late' }) must send POST /orders/7/cancel with the body {"reason":"Late"}.
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 };

// A fake server: records every request and returns canned data.
const sent: string[] = [];
async function transport(method: string, path: string, body: unknown): Promise<unknown> {
  sent.push(`${method} ${path}${body === undefined ? '' : ' ' + JSON.stringify(body)}`);
  if (path.endsWith('/cancel')) return { id: 1042, status: 'cancelled', cancelReason: 'Changed mind' };
  if (path === '/orders') return { items: [], nextCursor: null };
  return { id: 1042, status: 'processing', totalCents: 18900 };
}

type Routes = {
  'GET /orders/:id': { input: { id: number }; output: Order };
  'GET /orders': { input: { status?: OrderStatus; cursor?: string }; output: Page<Order> };
  // Add: POST /orders/:id/cancel takes { id, reason } and returns a CancelledOrder
};

async function call(route: string, input: Record<string, unknown>): Promise<unknown> {
  const [method, path] = route.split(' ');
  // Path parameters like :id are not filled in yet.
  return transport(method, path, method === 'GET' ? undefined : input);
}

// ---- Type tests (do not edit) ----
async function typeTests() {
  const order = await call('GET /orders/:id', { id: 1042 });
  type T1 = Expect<Equal<typeof order, Order>>;

  const page = await call('GET /orders', { status: 'shipped' });
  type T2 = Expect<Equal<typeof page, Page<Order>>>;

  const cancelled = await call('POST /orders/:id/cancel', { id: 1042, reason: 'Changed mind' });
  type T3 = Expect<Equal<typeof cancelled, CancelledOrder>>;

  // @ts-expect-error: ids are numbers
  await call('GET /orders/:id', { id: '1042' });
  // @ts-expect-error: cancelling needs a reason
  await call('POST /orders/:id/cancel', { id: 1042 });
  // @ts-expect-error: there is no such route
  await call('DELETE /orders/:id', { id: 1042 });
}
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Console

(no output)

Checks

  • The file type-checks with zero errors, including all type tests
  • Path parameters are filled in: GET /orders/1042
  • The cancel route posts to /orders/7/cancel with only the remaining fields as the body