DEV Community

Cover image for Conditional Types: When They Earn Their Complexity and When They Don't
Gabriel Anhaia
Gabriel Anhaia

Posted on

Conditional Types: When They Earn Their Complexity and When They Don't


A junior on your team opens a PR with a 14-line conditional type at the top of the user service. It widens the return type of a wrapper so callers see a stricter shape. It works. Reviews stall for two days because nobody on the review thread can read it without a whiteboard.

You sit with the type for ten minutes and replace it with a four-line union. The wrapper still works. The reviewers move on.

That is the question every conditional type has to answer: is the structural inference worth what it costs the next reader? Sometimes the answer is yes and the type pays for itself across a hundred call sites. Sometimes the same shape would be a literal union you can read at a glance.

The shape, in one paragraph

A conditional type is T extends X ? A : B. The compiler reads it as a question: if T is assignable to X, evaluate to A, otherwise B. The infer keyword inside X opens a slot the compiler can fill from the structure of T. The TypeScript handbook on conditional types is the reference for the full grammar. Reach for it when you cannot write the answer down by hand because it depends on a generic argument the call site supplies.

Case 1: overload return-type narrowing

A function returns different shapes for different inputs. A generic fetch wrapper that returns the parsed body for GET and the response status for POST is a common one. You want callers to see the right shape per call site without writing two function names.

type RequestKind = "get" | "post";

type RequestResult<K extends RequestKind, T> =
  K extends "get" ? T : { status: number };

async function request<K extends RequestKind, T>(
  kind: K,
  url: string,
): Promise<RequestResult<K, T>> {
  const res = await fetch(url, { method: kind.toUpperCase() });
  if (kind === "get") {
    return (await res.json()) as RequestResult<K, T>;
  }
  return { status: res.status } as RequestResult<K, T>;
}

const user = await request<"get", { id: string }>("get", "/me");
const ack  = await request<"post", never>("post", "/login");
Enter fullscreen mode Exit fullscreen mode

user is { id: string }. ack is { status: number }. One function, two return shapes, both visible at the call site. The two as RequestResult<K, T> casts in the body are the price of admission: TypeScript cannot prove the conditional narrows on the runtime kind === "get" check, so you assert it once at the boundary and the call sites stay clean.

You could write this as two functions, or as overloads. Both work. The conditional type wins when the kind is itself a generic threaded through several layers, like a fetcher passed into a hook, into a query cache, into a component. Overloads stop composing across that path. The conditional type does not.

Awaited<T> ships in TypeScript's standard library types and is the same idea applied to promise unwrapping. It walks any thenable structurally and resolves to the value type, recursing through nested promises.

Case 2: filtering variants out of a union

never is the empty type. Distributive conditional types treat it as the absence of a member. The conditional distributes over the input union, evaluates the branch per member, drops the members that resolve to never, and rejoins what remains. That is the cleanest way to filter a union by predicate.

type Without<T, U> = T extends U ? never : T;

type AllEvents =
  | { kind: "click"; x: number }
  | { kind: "key"; code: string }
  | { kind: "scroll"; dy: number };

type NoKeyOrScroll =
  Without<AllEvents, { kind: "key" } | { kind: "scroll" }>;
Enter fullscreen mode Exit fullscreen mode

NoKeyOrScroll is { kind: "click"; x: number }. The key and scroll members hit the T extends U branch, resolve to never, and drop out of the resulting union.

The standard library ships both shapes already: Exclude<T, U> is Without, NonNullable<T> is the null-stripping variant. The reason to write your own is when the predicate is structural rather than a fixed type set. A discriminated-union filter that keeps only variants whose kind matches a parameter is the canonical example.

type ByKind<U, K> = U extends { kind: K } ? U : never;

type ClickEvent = ByKind<AllEvents, "click">;
Enter fullscreen mode Exit fullscreen mode

ClickEvent is { kind: "click"; x: number }. The same pattern is what discriminated-union narrowing relies on at runtime, lifted into the type system.

Case 3: extracting shapes from generics with infer

The Result<T, E> pattern shows up in any codebase that takes errors seriously. You want a helper that pulls the success type out of a Result regardless of the error variant.

type Result<T, E> =
  | { ok: true; value: T }
  | { ok: false; error: E };

type Success<R> = R extends { ok: true; value: infer V } ? V : never;
type Failure<R> = R extends { ok: false; error: infer E } ? E : never;

type R = Result<{ id: string }, "not_found" | "forbidden">;

type S = Success<R>; // { id: string }
type F = Failure<R>; // "not_found" | "forbidden"
Enter fullscreen mode Exit fullscreen mode

infer V opens a slot the compiler fills from the matching branch. The losing branch is dropped. The same shape gives you Parameters<T> and ReturnType<T>, which are conditional types with infer on the relevant slot.

The reason this earns its complexity is what it does at the boundary. A function that accepts Result<T, E> and returns the success value can be written once and applied across every kind of result the codebase produces.

function unwrap<R extends Result<any, any>>(r: R): Success<R> {
  if (r.ok) return r.value as Success<R>;
  throw new Error(`unwrap on err: ${String(r.error)}`);
}
Enter fullscreen mode Exit fullscreen mode

The as Success<R> cast is needed because TypeScript narrows r on the ok discriminant at runtime but cannot prove r.value is Success<R> for the input generic R. String(r.error) is safer than JSON.stringify for an unconstrained E — bigints and circular references would throw inside the throw and you would lose the original failure.

unwrap(myUserResult) returns the user shape. unwrap(myOrderResult) returns the order shape. The conditional type is doing the work the call site would otherwise do with a cast.

Where conditional types do not earn their cost

The three cases above involve a generic argument the type author cannot see. The cases below do not, and that is the line.

Anti-pattern 1: a conditional type for a literal union

If the inputs are a fixed set of strings, the answer is a fixed mapping. A conditional type adds reading cost without buying anything in return.

// Don't.
type StatusColor<S> =
  S extends "ok" ? "green" :
  S extends "warn" ? "yellow" :
  S extends "fail" ? "red" : never;

// Do.
const colors = {
  ok: "green",
  warn: "yellow",
  fail: "red",
} as const;

type StatusColor = (typeof colors)[keyof typeof colors];
Enter fullscreen mode Exit fullscreen mode

The mapped object is the source of truth at runtime and the type comes for free.

Anti-pattern 2: a conditional type used at exactly one call site

The cost of a type alias is paid where it is read, not where it is defined. A conditional type used in one function should be inlined.

// Don't.
type FirstArg<F> =
  F extends (a: infer A, ...rest: any[]) => any ? A : never;

function logFirst<F extends (...a: any[]) => any>(
  fn: F,
  arg: FirstArg<F>,
) {
  console.log(arg);
  fn(arg);
}

// Do.
function logFirst<A>(
  fn: (a: A, ...rest: any[]) => any,
  arg: A,
) {
  console.log(arg);
  fn(arg);
}
Enter fullscreen mode Exit fullscreen mode

The second version reads as TypeScript. The first version reads as type-system gymnastics for a problem that did not need them. If the only reader is one function, write the generic on the function.

Anti-pattern 3: generics that ergonomically hurt at the call site

Some conditional types are correct and still wrong, because they push a tax onto every call site. The classic shape is a wrapper that infers a return type from a key the caller passes as a string.

type ResolveKey<K> =
  K extends "user" ? { id: string; email: string } :
  K extends "order" ? { id: string; total: number } :
  K extends "invoice" ? { id: string; due: string } : never;

function load<K extends "user" | "order" | "invoice">(
  k: K,
): Promise<ResolveKey<K>> {
  return fetch(`/api/${k}`).then((r) => r.json());
}
Enter fullscreen mode Exit fullscreen mode

It works. The cost is that every helper in the file has to thread K through its own generic to keep the inference alive, and the day you add an audit key you edit the union and the conditional and the runtime fetch path. A function per resource is easier to grep, easier to mock, and does not push a generic on every wrapper.

How to decide

Three questions, in order.

  1. Does the answer depend on a generic the type author cannot see at definition time? If no, write the union or the lookup.
  2. Is the type used in more than one place? If no, inline it.
  3. Will the call site read clearer with the conditional in it? If not, the abstraction is the wrong shape.

Conditional types are how Awaited, Parameters, ReturnType, Exclude, and NonNullable are written. That is also where the bar sits. If you are not building a primitive of similar generality, the literal version is usually right.


If this was useful

Conditional types are a chapter in The TypeScript Type System, sitting between the distributive-union machinery and the infer patterns that build on top of it. The book covers when the conditional shape pays for itself across a real codebase and when a mapped type or a literal union is the better answer. If the three cases above made you want to write your own Awaited, that is the book.

If you are coming from JVM languages, the conditional-type machinery covers the same ground that variance and sealed-class hierarchies do for you in Kotlin. Kotlin and Java to TypeScript makes that bridge. If you are coming from PHP 8+, PHP to TypeScript covers generics and inference from the other side. If you are shipping TS at work, TypeScript in Production covers the build, monorepo, and dual-publish concerns the type system itself does not touch.

The five-book set:

  • TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser — entry point: amazon.com/dp/B0GZB7QRW3
  • The TypeScript Type System — From Generics to DSL-Level Types — deep dive: amazon.com/dp/B0GZB86QYW
  • Kotlin and Java to TypeScript — A Bridge for JVM Developers — bridge for JVM devs: amazon.com/dp/B0GZB2333H
  • PHP to TypeScript — A Bridge for Modern PHP 8+ Developers — bridge for PHP devs: amazon.com/dp/B0GZBD5HMF
  • TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes — production layer: amazon.com/dp/B0GZB7F471

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (0)