Conditional Types: Deriving Types Based on Conditions
AI generated
<T>
type
TypeScript · Conditional Types · Generics · Utility Types
Conditional Types: Deriving Types Based on Conditions
Understanding, using, and knowing when to avoid T extends U ? X : Y

Conditional types let you make the type of an expression depend on a condition over another type. They sit behind almost every built in utility type, such as Exclude, ReturnType, or Awaited, and solve problems that would otherwise only be solvable with type assertions or duplicated code. This article covers the syntax, practical use cases, and the point where readability breaks.

13 min read T extends U ? X : Y · infer · Distributive Types TypeScript 5.x

1. The Problem: Types That Depend on Other Types

A static type system usually describes a fixed shape: a value has type string, an object has exactly these fields. In practice, though, the correct type of an expression often depends on another type. The return type of a function depends on its signature, a field should only exist when another field carries a particular literal value, or a union should be narrowed down to exactly the variants that fit a concrete context. Without a language feature for this, the only options are reaching for any, writing manual type assertions, or maintaining duplicate type definitions that drift apart with every change to the source.

Conditional types solve exactly this problem: they let you express a type as a condition over another type, evaluated by the compiler at compile time. This is an advanced feature that most developers rarely write themselves but rely on constantly and indirectly. Anyone using ReturnType<typeof fn>, Exclude<A, B>, or Awaited<Promise<T>> is already relying on conditional types defined inside the TypeScript standard library. Understanding how they work makes compiler error messages easier to read and lets you actually follow the type definitions of libraries such as GraphQL codegen output or ORM query builders, instead of accepting them as a black box.

2. Fundamentals: The T extends U ? X : Y Syntax

A conditional type has the form T extends U ? X : Y and is deliberately reminiscent of the ternary operator at the value level, but it operates entirely at the type level. If T is assignable to U, the whole expression resolves to X, otherwise to Y. The keyword extends does not mean class inheritance here, but assignability: T extends U checks whether every value of type T would also be a valid value of type U. This check works the same way for primitive types, object shapes, function signatures, and literals.

A simple example makes the mechanism tangible: type IsString<T> = T extends string ? true : false; resolves immediately for concrete types. Things get more interesting once T is a generic type parameter that only gets filled in once it is used with a concrete type. In that case the compiler does not evaluate the conditional type right away, but only once the type parameter has actually been substituted. This deferred evaluation behavior is the foundation for the distributive behavior over union types described in the next section.


// Basic conditional type: mirrors a ternary operator, but operates on types
type IsString<T> = T extends string ? true : false;

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

// Conditional types compose well with indexed access types
type Flatten<T> = T extends unknown[] ? T[number] : T;

type Single = Flatten<string>;    // string, T is not an array, Y branch
type Element = Flatten<number[]>; // number, T is an array, X branch resolves the element type

3. Distributive Conditional Types Over Union Types

When a conditional type is checked with a so called naked type parameter, meaning directly T extends U ? X : Y without wrapping T in an array, a tuple, or another container, and T is a union type at the time of instantiation, TypeScript automatically distributes the conditional type over each member of the union individually. The result is the union of the individual evaluations, not a single evaluation over the entire union. This behavior is called a distributive conditional type and is one of the most commonly misunderstood details of the TypeScript type system.

In practice this means: ToArray<string | number> is not evaluated as a single check over string | number, but as ToArray<string> | ToArray<number>, which resolves to string[] | number[]. If you want to deliberately prevent this distribution, for example because the union should be checked as a whole, you wrap T in a tuple: [T] extends [U] ? X : Y. A tuple with exactly one element is no longer a naked type parameter, which suppresses distribution. This exact trick is used in several advanced utility types whenever a union must be treated as a connected type instead of member by member.


// Distributive conditional type: a naked (bare) type parameter distributes over a union
type ToArray<T> = T extends unknown ? T[] : never;

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

// Wrapping the checked type in a tuple disables distribution
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;

type NonDistResult = ToArrayNonDist<string | number>;
// The union is checked as a whole: (string | number)[]

// This distribution is exactly how NonNullable<T> works internally
type WithoutNullish<T> = T extends null | undefined ? never : T;
type CleanUnion = WithoutNullish<string | null | number | undefined>;
// string | number, each union member is checked and filtered individually

4. In Practice: Extracting Return and Parameter Types

Before ReturnType and Parameters became a fixed part of the TypeScript standard library, developers had to write these extractions themselves, and the same pattern still pays off today whenever a specialized variant is needed, for example an AsyncReturnType that automatically unwraps a Promise. The underlying technique is always the same: a conditional type checks whether the given type matches a function signature, and uses infer to capture the part of the signature that matters.

In practice this cuts duplication significantly. Instead of manually maintaining a DTO structure in parallel with the signature of a repository method or an API client call, you derive the matching type directly from the function. If the signature changes, for example because a field is added to an order response, the derived type automatically follows along, without a second place in the code needing to be updated. This is especially valuable for test fixtures and mocks that must match exactly the shape a real function returns at runtime.


// Custom ReturnType implementation, the same technique the TS standard library uses
type MyReturnType<Fn extends (...args: never[]) => unknown> =
  Fn extends (...args: never[]) => infer Return ? Return : never;

// Custom Parameters implementation
type MyParameters<Fn extends (...args: never[]) => unknown> =
  Fn extends (...args: infer Args) => unknown ? Args : never;

declare function createOrder(customerId: string, items: string[]): { orderId: string };

type CreateOrderResult = MyReturnType<typeof createOrder>; // { orderId: string }
type CreateOrderArgs = MyParameters<typeof createOrder>;   // [customerId: string, items: string[]]

// Useful for deriving test fixtures without duplicating the function signature
const mockResult: CreateOrderResult = { orderId: "ORD-1001" };

5. In Practice: Filtering Union Members

Distributive conditional types are excellent for narrowing a broad union down to exactly the variants that matter in a given context. The basic pattern is always the same: T extends Pattern ? T : never keeps a member if it matches the pattern, and T extends Pattern ? never : T removes it. Because never automatically disappears from a union, distributing over all members ends up producing exactly the desired subset, with no manual filtering at runtime at all.

A realistic example is a discriminated union of events, such as { kind: "created" } | { kind: "updated" } | { kind: "error"; message: string }. With type OnlyErrors<T> = T extends { kind: "error" } ? T : never; you extract exactly the error variant, while type WithoutErrors<T> = T extends { kind: "error" } ? never : T; filters it out. This pattern is the foundation of Extract and Exclude from the standard library, and it is worth writing yourself whenever the standard utility types cannot express the desired condition exactly, for example when filtering on several fields at once.

6. The infer Keyword: Extracting Types From Structure

The keyword infer is only allowed inside the extends clause of a conditional type and declares a new type variable that is captured from the structure of the checked type. Instead of only asking whether T matches a pattern, infer lets you additionally name a part of that pattern and reuse it in the X branch. Without infer you would either need to already know that sub type in advance or extract it awkwardly through a separate indexed access construct.

infer can be used recursively, which is especially useful for nested structures such as multiply nested promises. When infer appears in several places within the same pattern, TypeScript combines the captured candidates into either a union or an intersection depending on their position, a detail that does not matter for most everyday use cases such as array element types or resolved promise values, but that can become relevant for very generic library types.


// infer introduces a new type variable captured from a matched structure
type ElementType<T> = T extends (infer Item)[] ? Item : never;

type ProductId = ElementType<number[]>; // number

// infer works recursively when combined with itself
type UnwrapPromise<T> = T extends Promise<infer Value> ? UnwrapPromise<Value> : T;

type Resolved = UnwrapPromise<Promise<Promise<string>>>; // string

// Practical example: extracting the payload type from an async repository call
declare function fetchCustomer(id: string): Promise<{ id: string; email: string }>;

type FetchCustomerResult = UnwrapPromise<ReturnType<typeof fetchCustomer>>;
// { id: string; email: string }

7. Built In Utility Types: Exclude, Extract, ReturnType, Awaited

Almost all utility types defined in lib.es5.d.ts and the newer lib.esnext.d.ts files of the TypeScript standard library are thin wrappers around exactly the techniques from the previous sections. Knowing their actual definitions demystifies compiler error messages that mention a conditional type and helps when building your own variants for special cases. type Exclude<T, U> = T extends U ? never : T; and type Extract<T, U> = T extends U ? T : never; are exactly the filter pattern from section 5, just generalized for any pattern U instead of a fixed object pattern.

type ReturnType<T> = T extends (...args: any) => infer R ? R : any; corresponds to the custom implementation from section 4, just with a looser function signature as the constraint. Awaited<T> goes one step further than the simple UnwrapPromise example from section 6, because it additionally resolves arbitrary thenable objects recursively through their then method, not just actual Promise instances. Anyone who needs a custom variant such as DeepAwaited for nested object structures with promise fields typically combines these building blocks with mapped types instead of starting from scratch.

8. Where Conditional Types Ruin Readability

Conditional types can be chained, and that is exactly what tempts developers into reproducing runtime logic with many branches one to one at the type level. A chain of five or six nested ternary expressions, combined with several infer declarations, is no problem for the compiler, but it is a burden for anyone who later reads or debugs the type. Compiler error messages for such constructs quickly become walls of text that obscure the actual cause of a type error instead of clarifying it, and every additional branch makes it exponentially, not linearly, harder to follow.

The better path once there are more than two or three branches is almost always a named helper type: a lookup map, a discriminated union following the pattern from section 5, or, for function related logic, a set of function overloads. Conditional types show their strength for tightly scoped, clearly named transformations such as extraction or filtering, not as a generic programming language at the type level. If you find yourself explaining a type definition more often than using it, you have usually chosen the wrong abstraction.


// Hard to read: deeply nested conditional chain emulating if/else if/else
type CssUnit<T> =
  T extends "px" ? number :
  T extends "%" ? `${number}%` :
  T extends "rem" ? `${number}rem` :
  T extends "vh" ? `${number}vh` :
  T extends "vw" ? `${number}vw` :
  never;

// Simpler and easier to extend: a lookup map instead of a growing ternary chain
interface CssUnitMap {
  px: number;
  "%": `${number}%`;
  rem: `${number}rem`;
  vh: `${number}vh`;
  vw: `${number}vw`;
}

type CssUnitLookup<T extends keyof CssUnitMap> = CssUnitMap[T];
// Same result, but adding a new unit means adding one line to an object,
// not inserting another branch into a growing ternary chain

9. Conditional Types in Direct Comparison

Not every condition at the type level belongs in a conditional type. The table below shows typical scenarios where a conditional type technically works, but an alternative is more maintainable in the long run.

Scenario Messy / risky Recommended alternative Benefit
More than 3 branches T extends A ? .. : T extends B ? .. : .. Lookup map / named helper type New cases are one line, not a new branch
Type from a function signature Structure manually duplicated and maintained ReturnType / infer based extraction Automatically stays in sync with the signature
Branching on a runtime value Clever conditional type over a literal union Discriminated union + switch Narrowing works for humans too, not just the compiler
Deeply recursive conditional type Unbounded recursion with no depth limit Cap recursion with a counter / base case Prevents an "excessively deep" compiler error
Filtering a union by context Runtime filtering with manual type guards Extract / Exclude pattern Filtering already happens at compile time

The rule of thumb from the table generalizes well: a conditional type is the right choice when it encapsulates a single, clearly named transformation, such as extracting, filtering, or unwrapping. Once a type starts reproducing multi layered business logic, a discriminated union with real narrowing in application code is almost always the more robust and more easily explained solution, even if the conditional type could technically produce the same result.

Mironsoft

TypeScript tooling, frontend architecture, and type safe headless integrations

Conditional types your team will still understand in a year?

We review existing type definitions for overly complex conditional type chains, replace them with readable alternatives where it makes sense, and build clean utility types for your Magento and headless stack where they actually pay off.

Type Level Review

Checking conditional types and utility types for readability and maintainability

API Typing

Deriving types directly from function signatures and GraphQL schemas

Build Tooling

Strict tsconfig settings and type checks in the CI pipeline

10. Summary

Conditional types solve a concrete problem: types that depend on the shape of another type instead of being rigidly fixed. The syntax T extends U ? X : Y behaves like a ternary operator at the type level, automatically distributes over union types when given a naked type parameter, and can be extended with infer to capture sub types. These exact building blocks sit behind Exclude, Extract, ReturnType, and Awaited, the most commonly used utility types in the TypeScript standard library.

The decisive point is the boundary of readability. Conditional types are ideal for tightly scoped transformations such as extracting, unwrapping, or filtering types. Once a chain of branches starts reproducing runtime logic one to one at the type level, a named helper type, a lookup map, or a discriminated union is almost always the better choice, because it stays understandable both for the compiler and for the next team member.

Conditional Types in TypeScript - The Key Points at a Glance

Basic Syntax

T extends U ? X : Y behaves like a ternary operator, but is evaluated entirely at the type level.

Distributive Behavior

Naked type parameters distribute the conditional type over each union member. [T] extends [U] disables that.

infer Keyword

Captures a sub type directly from the structure, the foundation of ReturnType, Parameters, and Awaited.

Readability Limit

From three or more branches on: a lookup map, discriminated union, or named helper type instead of further chaining.

11. FAQ: Conditional Types in TypeScript

1What is a conditional type in TypeScript?
A type that depends on a condition over another type, expressed as T extends U ? X : Y. The compiler evaluates the condition at compile time.
2How exactly does the T extends U ? X : Y syntax work?
extends checks assignability, not inheritance. If T is assignable to U, the expression resolves to X, otherwise to Y. For generic parameters, evaluation is deferred until substitution.
3What does distributive mean for conditional types?
With a naked type parameter of union type, TypeScript evaluates the conditional type for each member individually and combines the results into a union.
4How do I prevent a conditional type from distributing over a union?
Wrap the checked type in a single element tuple: [T] extends [U] ? X : Y. That disables automatic distribution.
5What does the infer keyword do?
Declares a new type variable inside the extends clause that captures part of the checked structure and can be reused in the X branch.
6How are ReturnType and Parameters implemented internally?
Both are conditional types with infer: ReturnType captures the return type with infer R, Parameters captures the argument list as a tuple with infer Args.
7What is the difference between Exclude and Extract?
Exclude removes union members that match U. Extract keeps only the matching ones. Both rely on the same distributive filter pattern, just with the branches swapped.
8Why do I get Type instantiation is excessively deep and possibly infinite?
Usually with recursive conditional types that have no clear base case or too much recursion depth. An explicit counter variable or an early base case usually fixes it.
9When should I use a discriminated union instead of a conditional type?
From more than two or three branches on, or when runtime logic is being reproduced. A discriminated union with a switch is then easier for humans to follow.
10Can I combine conditional types with generic functions?
Yes. A generic function parameter can feed into a conditional type, so the return type automatically depends on the shape of the argument passed in.