Right, let’s get into the meat of it. Conditional types are the Swiss Army knife of TypeScript’s type system. They’re how we teach our types to make decisions, to branch out, to ask “if this, then that.” The syntax is deceptively simple, cribbing directly from JavaScript’s ternary operator. You’ve seen condition ? trueExpression : falseExpression. Well, meet its smarter, type-level cousin: T extends U ? X : Y.

It reads exactly as you’d expect: “If type T is assignable to type U, then this type is X; otherwise, it’s Y.” But extends here is the key. Don’t just think of it as class inheritance. Think of it in the broader TypeScript sense: “is assignable to.” Does T fit into a variable of type U? If yes, the true branch is taken.

The Basics: It’s Just a Ternary, But for Types

Let’s start with a dead-simple example. Imagine you’re writing a function that should only accept arrays or strings. For an array, you want to return the type of its elements. For a string, you just return the string itself. For anything else, you wave the white flag and return never (a great way to say “this shouldn’t happen”).

type ArrayElementType<T> = T extends (infer U)[] ? U : T;

type Test1 = ArrayElementType<string[]>; // string
type Test2 = ArrayElementType<string>;    // string
type Test3 = ArrayElementType<number[]>; // number

// And for our 'never' case:
type Test4 = ArrayElementType<{ id: number }>; // { id: number } (not never!)

Wait, hold on. That last one didn’t work. I just said we’d return never for anything else! This is the first “gotcha.” Our type T extends (infer U)[] is checking if { id: number } is assignable to an array. It’s not, so it takes the false branch, which is T. To fix this, we need a more explicit false branch.

type BetterArrayElementType<T> = T extends any[] // Check for any array
  ? T[number]     // Index with number to get the type of the elements
  : T extends string ? T : never; // Then check for string, otherwise never

type Test4 = BetterArrayElementType<{ id: number }>; // never. Good.

See? We’re already nesting conditionals. This is where the power starts to unfold. It’s not just a single if; it’s a full-blown logic tree for your types.

Distributive Conditional Types: The Magic and The Mayhem

Here’s where most people’s brains short-circuit, but stick with me. When T is a naked type parameter (a fancy way of saying just T, not T[] or SomeType<T>) and it’s a union, the conditional type becomes distributive.

This means the operation T extends U ? X : Y is distributed over each member of the union. It’s like mapping the conditional over each type in T. This is overwhelmingly useful, but it can feel like magic if you don’t know it’s happening.

type Naked<T> = T extends string ? 'string' : 'other';
type Wrapped<T> = [T] extends [string] ? 'string' : 'other';

// T is a naked type parameter (a union)
type TestNaked = Naked<string | number>; // "string" | "other"
// This is equivalent to:
// (string extends string ? 'string' : 'other') | (number extends string ? 'other' : 'other')

// T is *not* naked here; it's wrapped in a tuple
type TestWrapped = Wrapped<string | number>; // "other"
// This checks if the entire union [string | number] is assignable to [string]

The distributive property is what makes types like Exclude and Extract possible. They’re just conditional types under the hood, distributing over unions.

// How Exclude might be implemented
type MyExclude<T, U> = T extends U ? never : T;

type Result = MyExclude<'a' | 'b' | 'c', 'a' | 'b'>; // "c"
// Steps: 'a' extends 'a'|'b' -> never | 'b' extends 'a'|'b' -> never | 'c' extends... -> 'c'
// The union becomes: never | never | 'c' which simplifies to 'c'.

The infer Keyword: Peeking Inside Types

The real superpower arrives with infer. It allows you to declare a new type variable right in the middle of the extends clause. You’re basically saying, “Hey TypeScript, if T matches this pattern, go ahead and figure out what this part would be and hold it for me in this new variable U.”

It’s like pattern matching for types. Let’s revisit our array example properly.

type GetElementType<T> = T extends Array<infer U> ? U : T;

// For Array<string>, it matches Array<infer U>. It infers that U must be string.
type Test = GetElementType<Array<string>>; // string

This is infinitely more powerful than just checking for arrays. You can infer from any structure.

type GetReturnType<T> = T extends (...args: any[]) => infer R ? R : T;

type Fn = (x: number) => boolean;
type FnReturn = GetReturnType<Fn>; // boolean

type NotFn = { name: string };
type NotFnReturn = GetReturnType<NotFn>; // { name: string }

This is precisely how TypeScript’s built-in ReturnType<T> works. It’s not special compiler magic; it’s just a conditional type with infer.

Best Practices and Pitfalls

  1. Avoid Distribution When You Don’t Want It: Remember, distribution only happens for naked type parameters. If you want to check an entire union as a whole, wrap it in a tuple, an array, or a promise. [T] extends [U] is a common pattern to turn off distribution.

  2. Prefer infer Over Indexing: For getting an array’s type, you could do T[number]. But infer is often clearer and works for more complex patterns where indexing isn’t possible, like function return types or promise resolution types.

  3. never is Your Friend: Using never in the false branch is a strong signal that the true branch is the only valid path. It’s crucial for building robust type utilities that fail fast instead of returning any or unexpected types.

  4. Order Matters: The checks are evaluated in order. T extends string ? A : T extends number ? B : C is different from T extends number ? B : T extends string ? A : C. Put the more specific cases first.

Conditional types with infer are the foundation upon which almost all advanced TypeScript patterns are built. They’re what transform your type definitions from static descriptions into dynamic, intelligent programs that reason about your code. It feels a bit like wizardry at first, but once it clicks, you’ll wonder how you ever lived without it.