Siddhant Deval
Siddhant DevalAuthor
Senior Full-Stack Engineer·Aug 27, 2026·14 min read

Conditional Types & `infer`: Type-Level Control Flow

Conditional types are if-else at the type level. `infer` is a pattern-match variable binding. Together they enable type-level algorithms — and power every major TypeScript library from tRPC to Prisma to Zod.

Conditional Types & infer: Type-Level Control Flow

Types are a specification language — not an annotation layer. The most striking proof of that is conditional types: TypeScript has if-else at the type level. T extends U ? X : Y is not a curiosity. It is the implementation language for ReturnType<T>, Awaited<T>, Parameters<T>, and every complex type utility in every serious TypeScript library. Understanding it is the difference between consuming those utilities and being able to write them.

1. Conditional Type Syntax

The conditional type form T extends U ? X : Y reads as: "If T is assignable to U, produce X; otherwise produce Y." This evaluation happens entirely at the type level, at compile time.
typescript
// Basic conditional type
type IsString<T> = T extends string ? true : false

type A = IsString<string>   // true
type B = IsString<number>   // false
type C = IsString<'hello'>  // true  — literal "hello" is a subset of string

// Practical example: extract only function types from a union
type OnlyFunctions<T> = T extends (...args: any[]) => any ? T : never

type Mixed = string | number | (() => void) | ((x: number) => string)
type Functions = OnlyFunctions<Mixed>
// (() => void) | ((x: number) => string)

2. Distributive Conditional Types

When the checked type T is a naked type parameter (not wrapped), the conditional distributes over each union member:
typescript
// T is naked — the conditional distributes over the union
type ToArray<T> = T extends any ? T[] : never

type Result = ToArray<string | number>
// Distributes as:
// ToArray<string> | ToArray<number>
// = string[] | number[]

// NOT: (string | number)[]  — that would be the non-distributed result
This is the mechanism that makes Exclude<T, U> work:
typescript
// TypeScript's actual Exclude:
type Exclude<T, U> = T extends U ? never : T

type Letters = 'a' | 'b' | 'c' | 'd'
type WithoutAB = Exclude<Letters, 'a' | 'b'>
// Distributes:
// ('a' extends 'a'|'b' ? never : 'a') | ('b' extends 'a'|'b' ? never : 'b') |
// ('c' extends 'a'|'b' ? never : 'c') | ('d' extends 'a'|'b' ? never : 'd')
// =  never | never | 'c' | 'd'
// = 'c' | 'd'
 Three-column diagram. Left column: 'Input: string | number'. Center column: 'Conditional: T extends any ? T[] : never' with a fork splitting into two paths labeled 'T = string' and 'T = number'. Right column: 'Result: string[] | number[]' — two boxes joined by a union pipe. An annotation below reads: 'Distribution happens member-by-member — each union member is evaluated independently'. Caption: 'Distributive conditional types evaluate each union member separately when T is a naked type parameter in the extends position'.
Figure: Three-column diagram. Left column: 'Input: string | number'. Center column: 'Conditional: T extends any ? T[] : never' with a fork splitting into two paths labeled 'T = string' and 'T = number'. Right column: 'Result: string[] | number[]' — two boxes joined by a union pipe. An annotation below reads: 'Distribution happens member-by-member — each union member is evaluated independently'. Caption: 'Distributive conditional types evaluate each union member separately when T is a naked type parameter in the extends position'.

3. Disabling Distribution with Tuple Wrapping

Distribution is sometimes undesirable. Wrap both sides in a tuple to compare the union as a whole:
typescript
// Naked T — distributes
type IsNever<T> = T extends never ? true : false
type A = IsNever<never>  // boolean (?!) — never distributes as an empty union, yielding `never`, not `true`

// ✅ Tuple-wrapped — suppresses distribution
type IsNever<T> = [T] extends [never] ? true : false
type B = IsNever<never>   // true  — the union [never] is compared atomically to [never]
type C = IsNever<string>  // false
Crucial Requirement
[T] extends [never] is the canonical way to check if T is exactly never. The naked form T extends never ? true : false returns never when T = never because distribution over the empty union produces no branches at all, yielding never.

4. infer — Pattern-Match Variable Binding

infer introduces a type variable inside the extends clause that is bound to the matched shape and available in the true branch:
typescript
// Extract the return type of a function
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never

type A = ReturnType<() => string>         // string
type B = ReturnType<(x: number) => User>  // User
type C = ReturnType<string>               // never — string doesn't match the function shape

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

type D = ElementType<string[]>   // string
type E = ElementType<User[]>     // User
type F = ElementType<number>     // never

// Extract the first parameter type
type FirstParam<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never

type G = FirstParam<(id: string, options: Options) => void>  // string
 Two-row diagram. Row 1 labeled 'Input type': function signature (x: number) => string drawn as a box with labeled slots. Row 2 labeled 'Pattern: T extends (...args: any[]) => infer R': the pattern is overlaid on the input, with a red highlight on the return position labeled 'infer R — binds R = string'. A right arrow from the highlighted slot points to a box labeled 'True branch: R = string'. Below: annotation 'infer creates a new type variable bound to the matched position — only valid in the extends clause'. Caption: 'infer is a pattern-match binding — it names the type at a specific structural position and makes it available in the true branch'.
Figure: Two-row diagram. Row 1 labeled 'Input type': function signature (x: number) => string drawn as a box with labeled slots. Row 2 labeled 'Pattern: T extends (...args: any[]) => infer R': the pattern is overlaid on the input, with a red highlight on the return position labeled 'infer R — binds R = string'. A right arrow from the highlighted slot points to a box labeled 'True branch: R = string'. Below: annotation 'infer creates a new type variable bound to the matched position — only valid in the extends clause'. Caption: 'infer is a pattern-match binding — it names the type at a specific structural position and makes it available in the true branch'.

5. Classic infer Patterns

5.1 Parameters<T> — Extracting a Full Parameter Tuple

typescript
type Parameters<T> = T extends (...args: infer P) => any ? P : never

type P = Parameters<(id: string, count: number) => void>
// [id: string, count: number]  — a named tuple preserving parameter names

5.2 Awaited<T> — Unwrapping Nested Promises

typescript
// TypeScript's actual Awaited — recursive to unwrap arbitrarily deep promises
type Awaited<T> =
  T extends null | undefined ? T :
  T extends object & { then(onfulfilled: infer F, ...args: infer _): any } ?
    F extends (value: infer V, ...args: infer _) => any ?
      Awaited<V> :  // Recurse — the resolved value may itself be a thenable
      never :
  T               // T is not a thenable — return as-is

type A = Awaited<Promise<string>>              // string
type B = Awaited<Promise<Promise<number>>>     // number
type C = Awaited<string>                       // string (non-Promise passes through)

5.3 infer with Template Literals — Extracting String Segments

typescript
// Extract the route method from a string like "GET /users"
type ExtractMethod<T extends string> =
  T extends `${infer Method} ${string}` ? Method : never

type M = ExtractMethod<'GET /users'>     // "GET"
type N = ExtractMethod<'POST /orders'>   // "POST"

6. Recursive Conditional Types — Performance Considerations

Recursive conditional types can be powerful but expensive:
typescript
// ✅ Simple recursion — flatten a nested array one level
type Flatten<T> = T extends Array<infer E>
  ? E extends Array<any>
    ? Flatten<E>
    : E
  : T

type A = Flatten<string[]>       // string
type B = Flatten<string[][]>     // string
type C = Flatten<string[][][]>   // string
Performance / Safety Warning
TypeScript enforces a recursion depth limit (approximately 100 levels for conditional types). Recursive types that don't terminate quickly will cause a "Type instantiation is excessively deep" error. Always define a clear base case, and be wary of recursive utilities applied to user-provided types of unknown depth.
 Recursion tree diagram. Root node: Awaited<Promise<Promise<string>>>. First recursive call: Awaited<Promise<string>>. Second recursive call: Awaited<string>. Leaf node (base case): string. Each node shows the step label: 'T is a thenable → recurse with resolved value'. The leaf node is labeled 'T is not a thenable → return T'. Caption: 'Awaited<T> is a recursive conditional type — it unwraps Promise chains by recursing until it reaches a non-thenable base case'.
Figure: Recursion tree diagram. Root node: Awaited<Promise<Promise<string>>>. First recursive call: Awaited<Promise<string>>. Second recursive call: Awaited<string>. Leaf node (base case): string. Each node shows the step label: 'T is a thenable → recurse with resolved value'. The leaf node is labeled 'T is not a thenable → return T'. Caption: 'Awaited<T> is a recursive conditional type — it unwraps Promise chains by recursing until it reaches a non-thenable base case'.

Summary

ConceptRule
T extends U ? X : YType-level if-else — evaluates at compile time based on assignability
DistributionNaked type parameters distribute over unions automatically — each member is evaluated independently
[T] extends [U]Tuple wrapping suppresses distribution — the union is compared atomically
infer RBinds a type variable to the matched position in the extends clause
infer constraintinfer only works in the extends position — never in the true/false branches
Recursive depthTypeScript limits recursion to ~100 levels — always define a clear base case
IsNever<T>Use [T] extends [never] ? true : false — the naked form distributes incorrectly

What's Next

In Part 6, we cover Template Literal Types — the mechanism that makes TypeScript aware of string structure. Combined with infer, they enable type-safe route contracts, event handler maps, and CSS property validation with zero runtime overhead.
Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#TypeScript#Conditional Types#infer#Advanced TypeScript#Type-Level Programming
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.