TypeScript Conditional Types in Real API Design: Stop Writing Overloads

Most TypeScript developers hit a wall around the time they start writing their third function overload for the same function. You know the drill: same function, different argument shapes, different return types. You end up with five function foo(a: string): Bar declarations stacked on top of each other and a sixth implementation signature that looks like it was written by someone having a bad day. There’s a better way, and it involves conditional types.

Conditional types — T extends U ? X : Y — are TypeScript’s answer to type-level branching. They’re one of the most expressive features in the language, and they’re consistently underused because the mental model takes a minute to click. Once it does, you’ll start seeing everywhere you’ve been fighting the type system instead of using it.

This article is practical. We’ll move from the basic mechanics to real API design patterns you can ship tomorrow.


The Mental Model

The syntax reads almost like JavaScript’s ternary operator, but it operates on types, not values:

type IsString<T> = T extends string ? "yes" : "no";

type A = IsString<string>;  // "yes"
type B = IsString<number>;  // "no"
type C = IsString<"hello">; // "yes" — "hello" extends string

The extends keyword here means assignable to, not inherits from. If T could be passed wherever a U is expected, the condition is true.

This is already useful, but the real power shows up in two places: the infer keyword and distribution over unions.


The infer Keyword: Extracting Types You Don’t Know Yet

infer lets you pull a type out of a conditional type branch and give it a name. It only works inside the extends clause.

// Extract the return type of any function
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

type Fn = () => Promise<{ id: number; name: string }>;
type Result = ReturnType<Fn>; // Promise<{ id: number; name: string }>

You’re basically telling TypeScript: "I know this type has some inner shape. Whatever that shape is, call it R and give it back to me." It’s pattern matching for types.

This is already baked into the standard library (ReturnType, Parameters, InstanceType, Awaited) — but those are just examples of a general technique you can use anywhere.


Distributive Conditional Types: The Behavior That Surprises Everyone

Here’s where people get tripped up. When the type parameter T is a bare (unquoted) type variable and you pass a union, TypeScript distributes the conditional type over each member of the union:

type ToArray<T> = T extends any ? T[] : never;

type A = ToArray<string | number>;
// Becomes: ToArray<string> | ToArray<number>
// Which is: string[] | number[]

This is usually what you want. But it bites you when you don’t expect distribution:

type IsUnion<T> = T extends any ? (Exclude<T, T> extends never ? false : true) : never;
// This doesn't work the way you'd think — it always returns false

To opt out of distribution, wrap both sides of extends in square brackets:

type IsExactString<T> = [T] extends [string] ? true : false;

type A = IsExactString<string>;        // true
type B = IsExactString<string | null>; // false — not just string

The brackets prevent distribution. [string | null] extends [string] is a single check, not two checks distributed.


Real API Design Pattern 1: Response Type Based on Request Shape

This is the most common use case and the one that replaces function overloads.

Say you’re building an API client where the response shape depends on query parameters:

type ListParams = { page: number; limit: number };
type SingleParams = { id: string };

type ApiParams = ListParams | SingleParams;

// The return type should vary with the input
type ApiResponse<T extends ApiParams> =
  T extends SingleParams
    ? { data: { id: string; name: string } }
    : { data: { id: string; name: string }[]; total: number; page: number };

function fetch<T extends ApiParams>(params: T): Promise<ApiResponse<T>> {
  // implementation
}

// TypeScript now knows the exact return type at each call site
const single = await fetch({ id: "123" });
single.data.id; // ✅ — { id: string; name: string }

const list = await fetch({ page: 1, limit: 10 });
list.data[0].id; // ✅ — { id: string; name: string }[]
list.total;      // ✅ — number

No overloads. The conditional type does the branching at the type level based on what the caller actually passes.


Real API Design Pattern 2: Deep Transformation Utilities

Standard library Partial<T> and Readonly<T> only go one level deep. Conditional types let you build recursive versions:

type DeepReadonly<T> = T extends (infer U)[]
  ? ReadonlyArray<DeepReadonly<U>>
  : T extends object
  ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
  : T;

type Config = {
  server: {
    host: string;
    ports: number[];
  };
  auth: {
    providers: {
      name: string;
      secret: string;
    }[];
  };
};

type FrozenConfig = DeepReadonly<Config>;
// Every nested property and array is now readonly
// FrozenConfig["server"]["ports"] is ReadonlyArray<number>

The breakdown: if T is an array, make a readonly array of recursively-transformed elements. If it’s an object, map over its keys and recurse. Otherwise, return the primitive as-is.

This is genuinely useful when you’re loading config at startup and want TypeScript to catch any accidental mutation downstream.


Real API Design Pattern 3: Extracting Types from API Schemas

If you’re generating types from an OpenAPI spec or working with a typed route definition object, conditional types let you derive everything you need from a single source of truth:

// Your route definitions — single source of truth
type Routes = {
  "GET /users": {
    query: { page?: number };
    response: { users: User[]; total: number };
  };
  "POST /users": {
    body: { name: string; email: string };
    response: User;
  };
  "GET /users/:id": {
    params: { id: string };
    response: User;
  };
};

// Extract the response for any route
type RouteResponse<R extends keyof Routes> = Routes[R]["response"];

// Extract the request body if it exists, otherwise never
type RouteBody<R extends keyof Routes> = Routes[R] extends { body: infer B }
  ? B
  : never;

// Build a type-safe fetch function
declare function apiCall<R extends keyof Routes>(
  route: R,
  options: RouteBody<R> extends never
    ? { params?: Record<string, string>; query?: Record<string, unknown> }
    : { body: RouteBody<R>; params?: Record<string, string> }
): Promise<RouteResponse<R>>;

// Call sites are fully typed with no extra annotations
const users = await apiCall("GET /users", { query: { page: 1 } });
users.total; // ✅ number

const user = await apiCall("POST /users", {
  body: { name: "Ada", email: "[email protected]" }
});
user.email; // ✅ string

This pattern is what libraries like tRPC and Hono’s RPC mode use under the hood. You define the contract once and let conditional types derive everything downstream.


Real API Design Pattern 4: Conditional Middleware Typing

Express and Fastify middleware chains are notoriously hard to type correctly. Conditional types give you a way to express "this middleware augments the request type":

type WithAuth = { user: { id: string; roles: string[] } };
type WithParsedBody<T> = { body: T };

type AugmentedRequest<TAuth extends boolean, TBody> =
  Request &
  (TAuth extends true ? WithAuth : {}) &
  (TBody extends never ? {} : WithParsedBody<TBody>);

// Handler that requires auth
function protectedHandler(
  req: AugmentedRequest<true, never>
) {
  req.user.id; // ✅ typed, guaranteed to exist
}

// Handler with a parsed body, no auth
function publicHandler(
  req: AugmentedRequest<false, { email: string }>
) {
  req.body.email; // ✅
  // req.user — ❌ TypeScript error, as it should be
}

This beats typing every handler with a manual interface and hoping the middleware actually ran.


Gotchas

Distribution bites you with never. never is the empty union. Distributed over an empty union, a conditional type returns never — which silently eats your type:

type Wrap<T> = T extends any ? { value: T } : never;
type Result = Wrap<never>; // never — not { value: never }

If you need to handle never explicitly, check for it first:

type IsNever<T> = [T] extends [never] ? true : false;

Circular conditional types crash the compiler. TypeScript resolves conditional types eagerly, so a type that references itself in its own condition will either produce a circular reference error or silently resolve to any. If you need recursion, make sure there’s always a base case that bottoms out without self-reference (as in DeepReadonly above — primitives hit the final branch and return).

infer is position-sensitive. You can only use infer in covariant positions inside the extends clause. Trying to use it in a contravariant position (like a function parameter of a parameter) behaves unexpectedly. If something isn’t working, check where you placed infer.

Conditional types don’t narrow inside function bodies. If you write:

function process<T>(value: T): T extends string ? number : boolean {
  if (typeof value === "string") {
    return value.length; // ❌ TypeScript still complains
  }
  return true; // ❌ same problem
}

TypeScript can’t verify that the runtime branch matches the type branch. You’ll need a type assertion (as) inside the body, or restructure with overloads after all. Conditional types shine at the call site — what the caller sees — not inside the implementation.

Over-nesting becomes unreadable. Five levels of nested conditional types will make your PR reviewer hate you. If a conditional type is more than two levels deep, break it into named intermediate types. The compiler doesn’t care, but future-you does.


Production-Ready: Combining It All

Here’s a realistic typed API client that uses all of the above patterns together:

// api-client.ts

type Method = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";

type RouteDefinition = {
  method: Method;
  path: string;
  query?: Record<string, unknown>;
  body?: Record<string, unknown>;
  response: unknown;
};

// Extract only GET routes (no body)
type GetRoute<T extends RouteDefinition> = T extends { method: "GET" }
  ? T
  : never;

// Extract only mutation routes (have body)
type MutationRoute<T extends RouteDefinition> = T extends {
  method: "POST" | "PUT" | "PATCH";
  body: infer _B;
}
  ? T
  : never;

// Build options type based on whether route has a body
type RequestOptions<T extends RouteDefinition> = T extends { body: infer B }
  ? { body: B; headers?: Record<string, string> }
  : { headers?: Record<string, string> };

class ApiClient<TRoutes extends RouteDefinition> {
  constructor(private baseUrl: string) {}

  async request<T extends TRoutes>(
    method: T["method"],
    path: T["path"],
    options: RequestOptions<T>
  ): Promise<T["response"]> {
    const init: RequestInit = {
      method,
      headers: { "Content-Type": "application/json", ...options.headers },
    };

    if ("body" in options && options.body !== undefined) {
      init.body = JSON.stringify(options.body);
    }

    const res = await fetch(`${this.baseUrl}${path}`, init);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json();
  }
}

In practice you’d wrap this with route-specific methods, but the core typing is already doing the heavy lifting: RequestOptions<T> only demands a body when the route definition includes one, and the return type is inferred directly from the route definition.


When Not to Use Conditional Types

Conditional types are not a hammer for every nail.

If you’re writing a simple utility that works on a fixed set of types, a union or overload might be clearer. If the type computation is purely cosmetic (doesn’t affect what callers can do with the result), it’s probably not worth the complexity budget.

The right question is: does this conditional type give the caller better information than they’d get without it? If yes, use it. If it’s just rearranging names, skip it.


Summary

T extends U ? X : Y is how TypeScript expresses branching at the type level. Combine it with infer to extract nested types, use bracket notation to opt out of distribution, and build recursive utilities by bottoming out on primitives.

In real API design, the payoff is clear: response types that track request shapes, single-source route definitions, recursive transformers, and middleware augmentation — all without the maintenance burden of function overloads. The type system does the bookkeeping; you write the logic.

The gotchas are real but predictable. Know that never distributes to nothing, that implementations need assertions, and that deep nesting is a readability debt. Work within those constraints and conditional types become one of the most powerful tools in your TypeScript toolkit.

Leave a comment

👁 Views: 2,289 · Unique visitors: 1,646