Skip to content
C Codeloom
TypeScript

TypeScript Conditional Types Explained

T extends U ? X : Y, distribution over unions, and the infer keyword — the three rules that build every advanced TypeScript utility.

·8 min read · By Yash Kesharwani
Intermediate 11 min read

What you'll learn

  • The T extends U ? X : Y syntax
  • How conditional types distribute over unions
  • The infer keyword and where it lives
  • How to write Unpromise<T>, ReturnType, and friends
  • Pitfalls — and how to opt out of distribution

Prerequisites

Conditional types are the closest thing TypeScript has to control flow at the type level. Once you understand the three rules — the ternary form, distribution over unions, and infer — you can read and write the utility types that power most modern libraries. This post walks through each rule with examples you can keep.

The ternary at the type level

The syntax mirrors the value-level ternary:

// type-level
type IsString<T> = T extends string ? true : false;

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

Read it as: “if T is assignable to string, the type is true; otherwise, false.” extends here is the assignability test, not class inheritance.

Combine with generics for something useful:

type NonNullable<T> = T extends null | undefined ? never : T;

type X = NonNullable<string | null>;    // string
type Y = NonNullable<number | undefined>; // number

never is the “no values” type — when it appears in a union, it disappears. We will see why that matters in a moment.

Distribution over unions

Here is the rule that catches everyone the first time. When a conditional type’s checked type is a naked type parameter, the conditional distributes over each member of a union:

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

type A = ToArray<string | number>;
// Equivalent to: ToArray<string> | ToArray<number>
// = string[] | number[]
// NOT (string | number)[]

The compiler “splits” string | number into its members, applies the condition to each, and unions the results. That is why NonNullable<string | null> works — string keeps itself, null becomes never, and string | never is just string.

Distribution is what makes conditional types feel magical. It is also the source of every weird bug.

Opting out of distribution

Sometimes you want the conditional to apply to the whole union, not to each member. Wrap both sides of extends in a tuple:

type IsUnion<T> = [T] extends [string | number] ? true : false;

type A = IsUnion<string | number>;     // true
type B = IsUnion<string>;              // true (still assignable)

A common pattern: detecting never. Naked T extends never distributes — and a union of zero things is just never, so never extends never returns never from the distribution machinery. The wrapped form avoids that:

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

type A = IsNever<never>;       // true
type B = IsNever<string>;      // false

The infer keyword

infer introduces a fresh type variable inside a conditional’s extends clause. The compiler matches the pattern and binds the variable to whatever fits.

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

type A = ElementOf<string[]>;          // string
type B = ElementOf<number[]>;          // number
type C = ElementOf<{ id: 1 }[]>;       // { id: 1 }
type D = ElementOf<string>;            // never (not an array)

The pattern says: “if T is an array of some element type, name that element type U and return it; otherwise never.”

infer only makes sense inside extends in a conditional. You cannot use it elsewhere.

Inferring from Promise

The same trick unwraps a Promise:

type Unpromise<T> = T extends Promise<infer U> ? U : T;

type A = Unpromise<Promise<string>>;        // string
type B = Unpromise<Promise<number[]>>;      // number[]
type C = Unpromise<string>;                 // string (passthrough)

This is roughly TypeScript’s built-in Awaited<T>, though Awaited also handles nested Promises and then-able objects:

type DeepUnpromise<T> =
  T extends Promise<infer U> ? DeepUnpromise<U> : T;

type X = DeepUnpromise<Promise<Promise<string>>>;   // string

Recursion in conditional types is allowed and routinely useful.

Inferring from function types

infer shines on function signatures. Each piece can be captured separately:

type MyReturnType<F> = F extends (...args: any[]) => infer R ? R : never;

type A = MyReturnType<() => string>;             // string
type B = MyReturnType<(x: number) => boolean>;   // boolean

You just wrote ReturnType from scratch. The standard library version is the same shape.

Inferring parameters as a tuple:

type MyParameters<F> = F extends (...args: infer P) => any ? P : never;

type A = MyParameters<(x: number, y: string) => void>;
// [x: number, y: string]

Tuple parameter lists carry the labels, which is why hovers show [x: number, y: string] rather than [number, string].

Try it yourself. Write FirstParameter<F> that extracts the first parameter of a function type, or never if the function takes no parameters. Then write LastParameter<F> for the last parameter. Test on (a: number, b: string, c: boolean) => void.

Inferring from a class constructor

Same pattern, different signature:

type InstanceTypeOf<C> = C extends new (...args: any[]) => infer I ? I : never;

class User { constructor(public name: string) {} }

type U = InstanceTypeOf<typeof User>;    // User

This is the built-in InstanceType<T>. With infer, you can build every member of the standard utility set yourself.

A more practical example

Suppose you have a function that returns a discriminated union, and you want to extract one branch by its discriminant:

type Action =
  | { type: 'add'; value: number }
  | { type: 'remove'; id: string }
  | { type: 'reset' };

type ActionOf<K extends Action['type']> =
  Action extends infer A
    ? A extends { type: K } ? A : never
    : never;

type AddAction = ActionOf<'add'>;
// { type: 'add'; value: number }

The double-conditional is a common idiom: the outer Action extends infer A distributes the union, and the inner A extends { type: K } filters each branch. This is roughly how the built-in Extract<T, U> works.

Building ReturnType — and going further

You have already seen MyReturnType. Here is a sequence of small types built from the same pieces:

// First parameter
type Head<F> = F extends (first: infer H, ...rest: any[]) => any ? H : never;

// All but the first
type Tail<F> = F extends (first: any, ...rest: infer R) => any ? R : never;

// Return value
type Out<F> = F extends (...args: any[]) => infer R ? R : never;

type F = (id: number, name: string, age: number) => boolean;

type A = Head<F>;    // number
type B = Tail<F>;    // [name: string, age: number]
type C = Out<F>;     // boolean

Every advanced library — tRPC, Zod, Drizzle — relies on this style of inference to give you accurate types for code you never explicitly typed.

Conditional types meet keyof

Combined with keyof, conditional types let you pick keys by their value type:

type KeysMatching<T, V> = {
  [K in keyof T]: T[K] extends V ? K : never;
}[keyof T];

interface User {
  id: number;
  name: string;
  age: number;
  isAdmin: boolean;
}

type NumberKeys = KeysMatching<User, number>;  // 'id' | 'age'
type BoolKeys = KeysMatching<User, boolean>;   // 'isAdmin'

The mapped type produces { id: 'id', name: never, age: 'age', isAdmin: never }, then [keyof T] indexes by every key, giving the union of values. never members vanish from the union. The next post — TypeScript Mapped Types — covers this machinery in full.

Try it yourself. Write OmitByType<T, V> that removes all properties of T whose value type is V. Use KeysMatching and the built-in Omit. Test it on the User interface above with V = boolean.

Pitfalls

A short field guide to the bugs you will hit:

  • Accidental distribution. T extends any ? T[] : never distributes; if you want (string | number)[], wrap with [T] extends [any].
  • infer in the wrong slot. infer must appear in the extends clause of a conditional. It cannot live alone.
  • Variance surprises. extends checks assignability, which is not symmetric. string extends 'a' is false; 'a' extends string is true.
  • Distributive never. never extends X ? Y : Z is never, not Y or Z. The conditional never evaluates because there is nothing to distribute over.

When not to reach for conditional types

Powerful does not mean obligatory. If a plain function signature or a simple generic conveys the contract, prefer that. Conditional types pay for themselves in libraries with many users; in application code, they often add cognitive cost for little gain. The test: can a colleague unfamiliar with the file read the type in under a minute? If not, simplify.

Recap

You now know:

  • Conditional types use T extends U ? X : Y
  • Naked type parameters distribute over unions
  • [T] extends [U] opts out of distribution
  • infer introduces a placeholder bound by pattern matching
  • You can infer from arrays, Promises, function parameters, returns, and constructors
  • Built-in utilities like ReturnType, Awaited, and Extract are just conditional types

Next steps

Conditional types compute a type. Mapped types compute every key of a type. Together they make TypeScript’s most expressive features feel like a small consistent language. The next post builds Partial, Readonly, and friends from scratch.

→ Next: TypeScript Mapped Types: Readonly, Partial from Scratch

Questions or feedback? Email codeloomdevv@gmail.com.