Modern Web · Typescript

TypeScript Conditional Types — A Field Guide

How TypeScript's conditional types work: the T extends U ? X : Y syntax, distributive conditionals over unions, opting out with tuples, and the infer keyword for extracting nested types.

John Kihiu12 min read

Conditional types are the mechanism that lets TypeScript's type system branch: pick one type or another based on a check, the same way an if statement picks one value or another at runtime. Once you're comfortable with the syntax, the thing that actually trips people up is the automatic distribution over union types — a behavior that looks like a bug the first time you hit it, and is actually the feature. This is a walkthrough of how conditional types work, how to control that distribution, and how infer lets you pull types back out of generics instead of just checking them.

The basic syntax

A conditional type has the shape T extends U ? X : Y. Read it exactly like a ternary: if T is assignable to U, the type resolves to X; otherwise it resolves to Y. It's evaluated at compile time against whatever concrete type gets substituted in for T.

TypeScript
type IsString<T> = T extends string ? true : false;

type A = IsString<"hello">; // true
type B = IsString<42>;      // false

// A more useful example: pick a return type based on an input flag
type Response<T extends boolean> = T extends true
  ? { success: true; data: string }
  : { success: false; error: string };

function fetchData<T extends boolean>(ok: T): Response<T> {
  return (ok
    ? { success: true, data: "loaded" }
    : { success: false, error: "failed" }) as Response<T>;
}

Nothing here is exotic — it's the same generic-constraint logic you already use with extends in a normal generic signature, just evaluated to produce a type instead of just checking one. Where it gets interesting is what happens when T itself is a union.

Distributive conditional types over unions

When the checked type in a conditional type is a bare, naked type parameter, and you pass a union in for that parameter, TypeScript doesn't run the check once against the whole union — it distributes the conditional over each member of the union and unions the results back together. This is the single most confusing thing about conditional types until you've seen it happen once.

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

// T is a union, so this distributes:
// string extends unknown ? string[] : never
// number extends unknown ? number[] : never
// then unions the results
type Result = ToArray<string | number>;
// Result is string[] | number[] — NOT (string | number)[]

That distinction — string[] | number[] versus (string | number)[] — is exactly the behavior a built-in type like Exclude<T, U> relies on. Exclude is defined as T extends U ? never : T, and because it distributes, passing a union in for T filters each member independently against U, which is exactly what you want from something named "exclude."

Opting out with a tuple

Sometimes you want the conditional to treat the union as one thing and run the check once, not once per member. The trick is to wrap both sides in a one-element tuple. A tuple isn't a "naked" type parameter anymore, so TypeScript skips the distribution step and just checks assignability of the whole union at once.

TypeScript
// Distributive: checks each member of the union separately
type IsNeverDist<T> = T extends never ? true : false;
type A = IsNeverDist<never>; // true — as expected

// Non-distributive: wrap in a tuple to check the union as a whole
type IsNeverTuple<T> = [T] extends [never] ? true : false;
type B = IsNeverTuple<never>; // true

// The difference shows up with actual unions:
type Distributed = IsNeverDist<string | number>;   // false (checked per-member)
type NotDistributed = IsNeverTuple<string | number>; // false (checked as one type)
// but this is where they diverge:
type EmptyUnionDist = IsNeverDist<never> extends true ? "yes" : "no"; // "yes"
Distribution has a quirk with never

A distributive conditional type applied to never resolves to never itself, because distributing over an empty union produces an empty union. This is why checking "is this type never" reliably requires the tuple form — the naked version silently short-circuits before your branches ever run.

Extracting types with infer

The infer keyword can only appear inside the extends clause of a conditional type. It introduces a new type variable that TypeScript fills in by pattern-matching against the structure of T, instead of you having to already know the shape you're pulling out. This is how you get a type out of a generic rather than just checking it.

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

function greet() { return "hello"; }
type Greeting = MyReturnType<typeof greet>; // string

// Extract the element type of an array
type ElementType<T> = T extends (infer E)[] ? E : never;
type Item = ElementType<number[]>; // number

// Extract the resolved type inside a Promise
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type Resolved = UnwrapPromise<Promise<{ id: number }>>; // { id: number }
type Passthrough = UnwrapPromise<string>; // string, unchanged

Notice UnwrapPromise falls back to T itself in the else branch rather than never. That's a deliberate choice: it makes the utility safe to apply to a type you're not sure is wrapped in a promise, which is the common case when you're writing a helper that has to work across an API boundary where some calls are async and some aren't.

A real-world example: Flatten<T>

A pattern I reach for often enough that it's worth having memorized: a Flatten<T> utility that unwraps one level of array nesting, combined with infer recursing until it bottoms out. This is close to how TypeScript's own Awaited<T> utility type handles chained promises internally.

TypeScript
// Recursively unwrap arrays until we hit a non-array type
type Flatten<T> = T extends Array<infer Item> ? Flatten<Item> : T;

type A = Flatten<string>;           // string
type B = Flatten<string[]>;         // string
type C = Flatten<string[][][]>;     // string

// Same recursive-infer trick for chained promises,
// which is essentially what lib.es5's Awaited<T> does
type DeepAwaited<T> = T extends Promise<infer U> ? DeepAwaited<U> : T;

type D = DeepAwaited<Promise<Promise<number>>>; // number
Prefer the built-ins when they exist

TypeScript ships Awaited<T>, ReturnType<T>, Parameters<T>, Exclude<T, U>, and Extract<T, U> as standard library utility types, all built on exactly the conditional-type and infer patterns above. Writing your own version is a good learning exercise; reaching for the built-in is the right call in real code, since it's already handled the edge cases and everyone reading the codebase recognizes it immediately.

Wrapping up

Conditional types give TypeScript's type system an if/else, and infer gives it a way to bind variables inside that branch. The part worth internalizing is the distributive behavior: a naked type parameter checked against a union runs the check per-member and unions the results, which is exactly what utilities like Exclude depend on — and exactly what the [T] extends [U] tuple wrapper lets you turn off when you need to test the union as a single unit instead. Once those two rules are second nature, most of the type-level code you'll find in library .d.ts files stops looking like magic and starts looking like ordinary conditional logic with different syntax.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.