Skip to main content
Advertisement

5.4 Conditional Types

What Are Conditional Types?

Conditional types express if-else logic at the type level. The result type changes depending on the input type.

// Basic syntax
type ConditionalType = T extends U ? X : Y;
// ^^^^^^^^^^^^^^^^
// "If T is assignable to U, the result is X, otherwise Y"

The syntax resembles JavaScript's ternary operator, but it operates on types rather than values.

// Simple example
type IsString<T> = T extends string ? "yes" : "no";

type Test1 = IsString<string>; // "yes"
type Test2 = IsString<number>; // "no"
type Test3 = IsString<"hello">; // "yes" (string literal is a subtype of string)

Basic Syntax and Usage

Branching on Types

// Check for nullability
type IsNullable<T> = null extends T ? true : false;

type A = IsNullable<string | null>; // true
type B = IsNullable<string>; // false
type C = IsNullable<null>; // true

// Check whether a type is an array
type IsArray<T> = T extends unknown[] ? true : false;

type D = IsArray<number[]>; // true
type E = IsArray<string>; // false
type F = IsArray<never[]>; // true
type G = IsArray<readonly number[]>; // false (readonly array is not a subtype of unknown[])

// Check whether a type is a function
type IsFunction<T> = T extends (...args: any[]) => any ? true : false;

type H = IsFunction<() => void>; // true
type I = IsFunction<(x: number) => string>; // true
type J = IsFunction<string>; // false

Type Transformation with Conditional Types

// Return the element type if it's an array; otherwise return the type as-is
type Flatten<T> = T extends Array<infer Item> ? Item : T;

type Num = Flatten<number[]>; // number
type Str = Flatten<string>; // string (not an array → unchanged)
type Nested = Flatten<string[][]>; // string[] (only one level flattened)

// Remove null and undefined
type NonNullable2<T> = T extends null | undefined ? never : T;

type K = NonNullable2<string | null | undefined>; // string
type L = NonNullable2<number | null>; // number

Distributive Conditional Types

When a union type is passed to a conditional type, the type is automatically distributed over each member. This is the key behavior of distributive conditional types.

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

// Distribution over a union type
type Test = IsString<string | number | boolean>;
// = IsString<string> | IsString<number> | IsString<boolean>
// = "yes" | "no" | "no"
// = "yes" | "no"

For distribution to occur, the type parameter T must be a "naked type parameter" — the generic type parameter must appear directly before extends.

// Distributive conditional types — how Exclude and Extract work
type MyExclude<T, U> = T extends U ? never : T;

type Result1 = MyExclude<"a" | "b" | "c" | "d", "b" | "d">;
// = MyExclude<"a", "b" | "d"> // "a"
// | MyExclude<"b", "b" | "d"> // never
// | MyExclude<"c", "b" | "d"> // "c"
// | MyExclude<"d", "b" | "d"> // never
// = "a" | never | "c" | never
// = "a" | "c"

type MyExtract<T, U> = T extends U ? T : never;

type Result2 = MyExtract<"admin" | "user" | "guest", "admin" | "moderator">;
// = "admin"

Leveraging Distribution for Type Transformations

// Wrap each union member
type Wrap<T> = T extends any ? { value: T } : never;

type Wrapped = Wrap<string | number>;
// = { value: string } | { value: number }

// Convert each union member to an array
type ToArray<T> = T extends any ? T[] : never;

type ArrayUnion = ToArray<string | number>;
// = string[] | number[] (not (string | number)[])

// Convert to function types
type ToFunction<T> = T extends any ? () => T : never;

type FunctionUnion = ToFunction<string | number | boolean>;
// = (() => string) | (() => number) | (() => boolean)

The infer Keyword

infer infers a type inside a conditional type and assigns it a name. infer R means "infer the type here and call it R."

// Basic infer usage
type UnpackArray<T> = T extends Array<infer Item> ? Item : T;

type N = UnpackArray<number[]>; // number
type S = UnpackArray<string[]>; // string
type M = UnpackArray<string[][]>; // string[] (one level)

// Extract function return type
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

function greet(name: string): string {
return `Hello, ${name}!`;
}

type GreetReturn = MyReturnType<typeof greet>; // string

// Extract function parameter types
type MyParameters<T> = T extends (...args: infer P) => any ? P : never;

type GreetParams = MyParameters<typeof greet>; // [name: string]

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

function createUser(name: string, age: number, email: string): void {}
type FirstArg = FirstParameter<typeof createUser>; // string

Unwrapping Promises

// Extract T from Promise<T>
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;

type P1 = UnpackPromise<Promise<string>>; // string
type P2 = UnpackPromise<Promise<number[]>>; // number[]
type P3 = UnpackPromise<string>; // string (not a Promise)

// Recursively unwrap nested Promises
type DeepUnpackPromise<T> = T extends Promise<infer U>
? DeepUnpackPromise<U>
: T;

type P4 = DeepUnpackPromise<Promise<Promise<Promise<string>>>>;
// string

// In TypeScript 4.5+ use the built-in Awaited<T>
type P5 = Awaited<Promise<Promise<string>>>; // string

Extracting Types from Tuples

// Type of the first element
type Head<T extends unknown[]> = T extends [infer H, ...any[]] ? H : never;

type H1 = Head<[string, number, boolean]>; // string
type H2 = Head<[number]>; // number
type H3 = Head<[]>; // never

// Type of the last element
type Last<T extends unknown[]> = T extends [...any[], infer L] ? L : never;

type L1 = Last<[string, number, boolean]>; // boolean
type L2 = Last<[number]>; // number

// Tail (everything except the first element)
type Tail<T extends unknown[]> = T extends [any, ...infer Rest] ? Rest : never;

type T1 = Tail<[string, number, boolean]>; // [number, boolean]
type T2 = Tail<[string]>; // []

Nested Conditional Types

// Classify types into multiple branches
type TypeCategory<T> =
T extends string ? "string" :
T extends number ? "number" :
T extends boolean ? "boolean" :
T extends null ? "null" :
T extends undefined ? "undefined" :
T extends any[] ? "array" :
T extends object ? "object" :
"unknown";

type C1 = TypeCategory<string>; // "string"
type C2 = TypeCategory<42>; // "number"
type C3 = TypeCategory<boolean>; // "boolean"
type C4 = TypeCategory<null>; // "null"
type C5 = TypeCategory<number[]>; // "array"
type C6 = TypeCategory<{ a: 1 }>; // "object"

// Branch based on whether a property is readonly
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};

type IsReadonly<T, K extends keyof T> =
({ [P in K]: T[P] } extends { readonly [P in K]: T[P] }
? true
: false);

Disabling Distribution

When you do not want union distribution, wrap the type in a tuple: [T] extends [U].

// Default behavior: distribution occurs
type IsNever<T> = T extends never ? true : false;

type Test1 = IsNever<never>; // never (condition is never evaluated because of distribution)
type Test2 = IsNever<string>; // false

// Fixed: disable distribution
type IsNeverFixed<T> = [T] extends [never] ? true : false;

type Test3 = IsNeverFixed<never>; // true (correct)
type Test4 = IsNeverFixed<string>; // false

// Detecting union types without distribution
type IsUnion<T> = [T] extends [never]
? false
: T extends any
? ([Exclude<T, T>] extends [never] ? false : true)
: never;

// Why wrap in a tuple?
// When T = string | number:
// T extends U → string extends U | number extends U (distributed)
// [T] extends [U] → [string | number] extends [U] (no distribution)
// Practical example of disabling distribution
type Distribute<T, U> = T extends U ? T[] : never;
type NoDistribute<T, U> = [T] extends [U] ? T[] : never;

type D1 = Distribute<string | number, string>;
// string[] | never = string[] (distributed)

type D2 = NoDistribute<string | number, string>;
// never (string | number is not a subtype of string)

Practical Examples

IsArray<T>

type IsArray<T> = T extends readonly unknown[] ? true : false;

type R1 = IsArray<number[]>; // true
type R2 = IsArray<readonly string[]>; // true
type R3 = IsArray<[1, 2, 3]>; // true (tuples are arrays)
type R4 = IsArray<string>; // false
type R5 = IsArray<{ length: number }>; // false

UnpackPromise<T>

// Recursively remove all Promise layers
type UnpackPromise<T> = T extends Promise<infer Inner>
? UnpackPromise<Inner>
: T;

type U1 = UnpackPromise<Promise<string>>; // string
type U2 = UnpackPromise<Promise<Promise<number[]>>>; // number[]
type U3 = UnpackPromise<string>; // string

// Combined with a real function
async function fetchData(): Promise<{ id: number; name: string }> {
return { id: 1, name: "Alice" };
}

type FetchResult = UnpackPromise<ReturnType<typeof fetchData>>;
// { id: number; name: string }

FunctionReturnType<T>

// Extract the return type, unwrapping Promise for async functions
type FunctionReturnType<T extends (...args: any[]) => any> =
ReturnType<T> extends Promise<infer R>
? R
: ReturnType<T>;

async function fetchUser(id: number): Promise<User> {
return fetch(`/api/users/${id}`).then((r) => r.json());
}

function getVersion(): string {
return "1.0.0";
}

type UserType = FunctionReturnType<typeof fetchUser>; // User (Promise removed)
type VersionType = FunctionReturnType<typeof getVersion>; // string

DeepRequired<T>

// Make all nested properties required
type DeepRequired<T> = T extends object
? { [K in keyof T]-?: DeepRequired<NonNullable<T[K]>> }
: T;

interface Profile {
name?: string;
address?: {
street?: string;
city?: string;
zip?: string;
country?: {
code?: string;
name?: string;
};
};
preferences?: {
theme?: "light" | "dark";
language?: string;
};
}

type StrictProfile = DeepRequired<Profile>;
// {
// name: string;
// address: {
// street: string;
// city: string;
// zip: string;
// country: { code: string; name: string; };
// };
// preferences: { theme: "light" | "dark"; language: string; };
// }

Type Filtering

// Extract union members that match a specific structure
type FilterByProp<T, K extends string, V> =
T extends Record<K, V> ? T : never;

type Actions =
| { type: "ADD_TODO"; payload: { text: string } }
| { type: "REMOVE_TODO"; payload: { id: number } }
| { type: "CLEAR_ALL" }
| { type: "SET_FILTER"; payload: { filter: string } };

// Extract only actions that have a payload
type ActionsWithPayload = FilterByProp<Actions, "payload", unknown>;
// { type: "ADD_TODO"; payload: ... }
// | { type: "REMOVE_TODO"; payload: ... }
// | { type: "SET_FILTER"; payload: ... }

// Extract an action by its type value
type ExtractAction<T extends { type: string }, K extends T["type"]> =
T extends { type: K } ? T : never;

type AddTodoAction = ExtractAction<Actions, "ADD_TODO">;
// { type: "ADD_TODO"; payload: { text: string } }

Pro Tips

Debugging Conditional Types

// Break down complex conditional types step by step
type Complex<T> =
T extends string
? T extends `${infer Start}${infer End}`
? [Start, End]
: never
: never;

// Debugging: isolate each step
type Step1<T> = T extends string ? T : never; // only strings pass
type Step2<T extends string> = T extends `${infer S}${infer E}` ? [S, E] : never;

// Useful pattern for inspecting types in the IDE
type Debug<T> = { [K in keyof T]: T[K] }; // force type expansion
type DebugComplex = Debug<Complex<"hello">>;

// Tracing never
type TraceNever<T> =
[T] extends [never]
? "is never"
: `is type ${string & T}`;

type T1 = TraceNever<never>; // "is never"
type T2 = TraceNever<string>; // "is type string"

Breaking Down Complex Types into Meaningful Steps

// Bad pattern: expressing everything in one go
type OverlyComplex<T> =
T extends Array<infer E>
? E extends string
? E extends `${infer P}:${infer Q}`
? { prefix: P; suffix: Q }[]
: string[]
: E extends number
? number[]
: never
: never;

// Good pattern: split into meaningful named pieces
type ElementOf<T> = T extends Array<infer E> ? E : never;
type ParseColonString<S extends string> =
S extends `${infer P}:${infer Q}` ? { prefix: P; suffix: Q } : never;
type ParseStringArray<T extends string[]> = {
[K in keyof T]: T[K] extends string ? ParseColonString<T[K]> : never;
};

// Each step can be tested independently
type E = ElementOf<string[]>; // string
type P = ParseColonString<"key:value">; // { prefix: "key"; suffix: "value" }

Using never for Exhaustiveness Checks

// Verify that all union cases have been handled
type Exhaustive<T extends never> = T;

function handleAction(action: Actions): string {
switch (action.type) {
case "ADD_TODO":
return `Add: ${action.payload.text}`;
case "REMOVE_TODO":
return `Remove: ${action.payload.id}`;
case "CLEAR_ALL":
return "Clear all";
case "SET_FILTER":
return `Set filter: ${action.payload.filter}`;
default:
// Once all cases are handled, action is never
const exhausted: Exhaustive<typeof action> = action;
return exhausted;
}
}

// Adding a new type to Actions triggers a compile error in the switch above

Summary Table

ConceptSyntaxDescription
Basic conditional typeT extends U ? X : YX if T is assignable to U
Distributive conditionalAutomatically applied to union TEach member is evaluated individually
Disable distribution[T] extends [U]Wrap in a tuple to prevent distribution
Basic inferT extends F<infer R>Infer and name a type inside a condition
Extract array elementT extends Array<infer E>Extract the element type of an array
Extract return typeT extends (...) => infer RExtract the return type
Unwrap PromiseT extends Promise<infer U>Extract the inner type of a Promise
Recursive conditionalSelf-referencing conditional typeHandle nested structures
never distributionT extends never → neverWork around with [T] extends [never]

Up Next...

Section 5.5 covers Generic Patterns in Practice. You will implement real-world design patterns with generics — Repository pattern, Builder pattern, API response wrappers, React generic hooks, and a generic event emitter — and learn how to design flexible APIs while maintaining type safety throughout.

Advertisement