Engineering Notes
Home/Languages/TypeScript/Conditional Types
Languages · TypeScript

Conditional Types

A conditional type picks between two types based on a type-level extends check — the type-system equivalent of a ternary expression, evaluated at compile time.

💻 Languages● Intermediate⏱ 7 min read🛠️ 3 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • What a Conditional Type Is
  • Conditional Types With Generic Functions
  • Chained Conditional Types
  • Conditional Types With infer (Preview)
  • Practical Use: Narrowing a Generic API's Return Type
Part 1The Basics

1What a Conditional Type Is

A conditional type picks between two types based on a type-level extends check — the type-system equivalent of a ternary expression, evaluated at compile time.

ts
type IsString<T> = T extends string ? "yes" : "no";

type A = IsString<"hello">; // "yes"
type B = IsString<42>;        // "no"

T extends U here doesn't mean "T is exactly U" — it means "is T assignable to U," the exact same assignability check covered in the Type System Fundamentals topic, just performed at the type level instead of checking a value against a type.

T extends string branches to "yes" when true and "no" when false, the same assignability check used everywhere else
T extends string branches to "yes" when true and "no" when false, the same assignability check used everywhere else

2Conditional Types With Generic Functions

ts
function processValue<T>(value: T): T extends string ? string[] : T[] {
  if (typeof value === "string") {
    return value.split("") as any; // implementation needs a type assertion — see Common Pitfalls
  }
  return [value] as any;
}

Conditional types let a function's return type genuinely depend on which type its input turned out to be, rather than being fixed for every call.

3Chained Conditional Types

ts
type TypeName<T> =
  T extends string ? "string" :
  T extends number ? "number" :
  T extends boolean ? "boolean" :
  T extends undefined ? "undefined" :
  T extends Function ? "function" :
  "object";

type A = TypeName<string>;   // "string"
type B = TypeName<() => void>; // "function"
type C = TypeName<{ x: 1 }>;   // "object"

Chaining extends ? : repeatedly builds a type-level if/else-if chain — this pattern mirrors (and is directly inspired by) JavaScript's runtime typeof checks, just operating on types instead of values.

4Conditional Types With infer (Preview)

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

type A = ElementType<string[]>; // string
type B = ElementType<number>;    // number (not an array, falls through unchanged)

infer lets a conditional type capture a piece of the matched type into a new type variable, rather than just branching on true/false — full depth is its own topic (Infer Keyword), introduced here because conditional types and infer are used together constantly in practice.

5Practical Use: Narrowing a Generic API's Return Type

ts
type ApiResult<T> = T extends { error: string } ? never : T;

function unwrap<T>(response: T): ApiResult<T> {
  // ...
  return response as ApiResult<T>;
}

This pattern shows up often in typed API clients: a conditional type expresses "this function's return type depends on whether the input looked like an error shape or not," encoding a real invariant about the function's behavior directly into its signature.

Part 2Distribution and Internals

1Distributive Conditional Types — the Subtle Default Behavior

When the type being checked (T) is a naked type parameter (used directly, not wrapped in something like T[] or [T]), and you pass a union as T, the conditional type doesn't run once against the whole union — it runs separately for each member, then unions the results back together.

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

type Result = ToArray<string | number>;
// NOT (string | number)[] — instead: string[] | number[]

Mechanically, TypeScript evaluates this as if you'd written ToArray<string> | ToArray<number>, then evaluated each piece independently and unioned the results — this automatic per-member iteration is called distribution.

2Why Distribution Is Genuinely Useful

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

type Result = NonNullable<string | null | number | undefined>;
// distributes: string extends null|undefined ? never : string  → string
//               null extends null|undefined ? never : null      → never
//               number extends null|undefined ? never : number   → number
//               undefined extends null|undefined ? never : undefined → never
// unions the results: string | never | number | never  → simplifies to: string | number

This is EXACTLY how the real NonNullable<T> utility type (from the Utility Types topic) works — distribution is what lets a single, simple conditional type correctly filter every member of an arbitrary union, without you writing any explicit loop or union-handling logic at all.

3Opting OUT of Distribution

Sometimes you want the conditional type to treat the union as one single thing, not distribute over it. Wrapping both sides in a tuple ([T] instead of bare T) disables distribution, because the type being checked is no longer a "naked" type parameter:

ts
type IsUnion<T> = [T] extends [any] ? false : never; // (illustrative — real IsUnion is trickier)

type ToArrayNonDistributive<T> = [T] extends [any] ? T[] : never;

type Result = ToArrayNonDistributive<string | number>;
// (string | number)[] — the WHOLE union treated as one T, no per-member splitting

Comparing this against Part 1/section 1's ToArray<string | number> result (string[] | number[]) shows the exact practical difference the tuple wrapping makes.

Under the HoodHow the Checker Actually Evaluates These▾

Conditional types are evaluated lazily — deferred until T is concrete enough

ts
type IsString<T> = T extends string ? "yes" : "no";

function check<T>(value: T): IsString<T> {
  // Inside this generic function, T is not yet a concrete type —
  // IsString<T> cannot be resolved to "yes" or "no" YET.
  // The checker defers evaluation, carrying `IsString<T>` around
  // symbolically, until check() is actually CALLED with a real type.
}

check("hello"); // NOW T = string is concrete, and IsString<string> resolves to "yes"

This deferral is necessary because the checker cannot know, while still checking the generic definition of check, whether a not-yet-determined T will or won't be assignable to string — resolution genuinely has to wait for instantiation, exactly mirroring how generic type inference itself (Generics topic) only resolves T at each call site, not at the definition site.

Distribution happens by literally iterating the union's constituent list

Recall from the Union Types topic's "Under the Hood" section that a union type is internally represented as a list of constituent types. Distribution over a conditional type is implemented by iterating that exact list: for each constituent, substitute it in for the naked type parameter, evaluate the conditional independently, then collect all the results back into a new union. This is precisely why wrapping in [T] disables it — a tuple [T] is a single, atomic type as far as the naked-parameter check is concerned, so there's no constituent list to iterate at all; the whole union goes in as one piece.

Circular/deeply recursive conditional types get a real recursion depth limit

ts
type Recurse<T> = T extends [infer First, ...infer Rest] ? [First, ...Recurse<Rest>] : [];

Patterns like this (common in advanced type-level programming, e.g. tuple manipulation) are genuinely evaluated by the checker step by step, similar in spirit to recursive function evaluation — and, like the Call Stack topic's real recursion limits, the type checker enforces its own instantiation-depth limit (to prevent runaway/infinite type computation from hanging the compiler), producing a "type instantiation is excessively deep" error if a conditional type recurses too far for a given input.

⚠️ Common Pitfalls

  1. Forgetting distribution happens automatically — a conditional type over a naked type parameter distributes by default; if you wanted the whole union treated as one unit, you must deliberately opt out with the tuple-wrapping trick.
  2. Distributing over never — never is the empty union (a union of zero types); distributing a conditional type over never produces never immediately, regardless of the conditional's branches — a source of confusing "why did this become never" bugs.
  3. Using a type assertion to paper over an unresolved conditional type inside a generic function body — often necessary in practice (section 2 of Part 1), but understand it's a genuine escape hatch, not a sign the type itself is wrong.
  4. Hitting the type instantiation depth limit on genuinely deep recursive conditional types — a sign the type-level algorithm needs restructuring (often via infer and accumulator-style type parameters), not just a compiler quirk to fight around.

📋 Quick Reference

text
T extends U ? X : Y                    → type-level ternary, based on assignability
naked T, T = union                      → conditional DISTRIBUTES over each member automatically
[T] extends [U] ? X : Y                   → disables distribution — union treated as one unit
generic function body                       → conditional stays unresolved until called with a concrete T

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1What is a conditional type?core▾

T extends U ? X : Y: chooses a type based on assignability, enabling type-level logic, e.g. type IsString<T> = T extends string ? true : false.

Follow-ups interviewers ask
  • Used where? Utility types, library typings, API response mapping.
Q2What are distributive conditional types?core▾

When the checked type is a naked type parameter and receives a union, the condition applies to each member separately: Exclude<'a' | 'b', 'a'> gives 'b'. Wrap in brackets ([T] extends [U]) to prevent distribution.

Follow-ups interviewers ask
  • Why prevent distribution? To check the union as a whole, e.g. detecting never.
Q3How does infer work?core▾

Inside a conditional's extends clause, infer declares a type variable to capture part of a type: type ElementType<T> = T extends (infer E)[] ? E : never.

Follow-ups interviewers ask
  • Unwrap a promise? T extends Promise<infer U> ? U : T.
Q4Give a practical conditional type.senior▾

Mapping API endpoints to response types, making function return types depend on arguments (T extends 'json' ? object : string), or extracting props types from components.

Follow-ups interviewers ask
  • Overloads alternative? Sometimes clearer for users.
Q5What are the downsides of complex conditional types?core▾

Hard-to-read errors, slower compilation, and fragile code. Keep them in library/utility layers, test them with type tests (expectTypeOf), and document them.

Follow-ups interviewers ask
  • Type testing tools? tsd, expect-type, vitest's expectTypeOf.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.