What if your types could make decisions? What if they could say "if this is a string, return string[]; if it's a number, return number[]; otherwise, return never"?
That's what conditional types do. They're the type-level equivalent of if statements. And combined with generics, they let you build types that adapt their behavior based on their inputs — the foundation of type-level programming.
You've already used conditional types with infer in Chapter 11. Now we go deeper: distributive conditional types, the never trick, nested conditions, and the patterns that make TypeScript's type system Turing-complete.
The Syntax: Type-Level If-Else
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
The syntax mirrors JavaScript's ternary operator: T extends U ? X : Y. If T is assignable to U, the result is X. Otherwise, Y.
Simple Conditions
type IsNumber<T> = T extends number ? true : false;
type IsBoolean<T> = T extends boolean ? true : false;
type IsArray<T> = T extends unknown[] ? true : false;
type IsFunction<T> = T extends (...args: unknown[]) => unknown ? true : false;
Nested Conditions
type TypeName<T> =
T extends string ? "string" :
T extends number ? "number" :
T extends boolean ? "boolean" :
T extends undefined ? "undefined" :
T extends null ? "null" :
T extends Function ? "function" :
T extends unknown[] ? "array" :
"object";
type A = TypeName<"hello">; // "string"
type B = TypeName<42>; // "number"
type C = TypeName<[1, 2, 3]>; // "array"
type D = TypeName<{ name: string }>; // "object"
Distributive Conditional Types
This is the most important concept in conditional types. When you apply a conditional type to a UNION, TypeScript distributes the condition over each member:
type ToArray<T> = T extends unknown ? T[] : never;
type Result = ToArray<string | number>;
// = ToArray<string> | ToArray<number>
// = string[] | number[]
The conditional type is applied to EACH member of the union separately, and the results are unioned back together. This is distribution.
Without distribution, ToArray<string | number> would be (string | number)[] — a single array type that can contain both strings and numbers. With distribution, it's string[] | number[] — either an array of strings OR an array of numbers.
When Distribution Happens
Distribution happens when:
- The checked type (
T) is a naked type parameter (justT, notT[]or{ x: T }) - The type parameter is instantiated with a union type
// Distributive — T is naked
type Distributive<T> = T extends unknown ? T[] : never;
// Non-distributive — T is wrapped in a tuple
type NonDistributive<T> = [T] extends [unknown] ? T[] : never;
type A = Distributive<string | number>; // string[] | number[]
type B = NonDistributive<string | number>; // (string | number)[]
Wrapping T in a tuple (or any other type constructor) prevents distribution. This is useful when you WANT to check the whole union at once.
The never Trick
never is the empty union. When you filter a union with a conditional type, members that resolve to never disappear:
type NonNullable<T> = T extends null | undefined ? never : T;
type A = NonNullable<string | null | undefined>;
// = string (null and undefined are removed)
This is how Exclude and Extract work:
type MyExclude<T, U> = T extends U ? never : T;
type MyExtract<T, U> = T extends U ? T : never;
type A = MyExclude<"a" | "b" | "c", "a" | "b">; // "c"
type B = MyExtract<"a" | "b" | "c", "a" | "b">; // "a" | "b"
Filtering Object Properties by Type
type PickByType<T, V> = {
[K in keyof T as T[K] extends V ? K : never]: T[K];
};
interface User {
name: string;
age: number;
email: string;
isAdmin: boolean;
}
type StringProps = PickByType<User, string>;
// { name: string; email: string; }
type NumberProps = PickByType<User, number>;
// { age: number; }
Conditional Types with infer
You've seen this in Chapter 11, but it's worth revisiting in the context of conditional types:
type ReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;
The infer keyword can only be used in the extends clause of a conditional type. It declares a type variable that TypeScript fills in by pattern matching.
Multiple infer Declarations
type Swap<T> = T extends [infer A, infer B] ? [B, A] : never;
type A = Swap<[string, number]>; // [number, string]
type B = Swap<[boolean, User]>; // [User, boolean]
infer with Rest Elements
type FirstAndRest<T> = T extends [infer First, ...infer Rest] ? { first: First; rest: Rest } : never;
type A = FirstAndRest<[string, number, boolean]>;
// { first: string; rest: [number, boolean] }
Recursive Conditional Types
Conditional types can be recursive, allowing you to process arbitrarily nested structures:
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? T[K] extends Function
? T[K]
: DeepReadonly<T[K]>
: T[K];
};
interface Config {
server: {
host: string;
port: number;
};
database: {
url: string;
pool: {
min: number;
max: number;
};
};
}
type ReadonlyConfig = DeepReadonly<Config>;
// All properties at all levels are readonly
Recursive Type with Base Case
Every recursive type needs a base case to terminate:
type Flatten<T> = T extends (infer U)[] ? Flatten<U> : T;
type A = Flatten<number[][][]>; // number
type B = Flatten<string[]>; // string
type C = Flatten<number>; // number (base case — not an array)
Built-in Conditional Types
TypeScript ships with several conditional utility types:
// Exclude: Remove types from a union
type Exclude<T, U> = T extends U ? never : T;
// Extract: Keep only matching types
type Extract<T, U> = T extends U ? T : never;
// NonNullable: Remove null and undefined
type NonNullable<T> = T extends null | undefined ? never : T;
// ReturnType: Get the return type of a function
type ReturnType<T extends (...args: unknown[]) => unknown> =
T extends (...args: unknown[]) => infer R ? R : never;
// Parameters: Get the parameter types of a function
type Parameters<T extends (...args: unknown[]) => unknown> =
T extends (...args: infer P) => unknown ? P : never;
// ConstructorParameters: Get constructor parameter types
type ConstructorParameters<T extends abstract new (...args: unknown[]) => unknown> =
T extends abstract new (...args: infer P) => unknown ? P : never;
// InstanceType: Get the instance type of a constructor
type InstanceType<T extends abstract new (...args: unknown[]) => unknown> =
T extends abstract new (...args: unknown[]) => infer R ? R : never;
// Awaited: Unwrap a Promise
type Awaited<T> = T extends Promise<infer R> ? Awaited<R> : T;
Advanced Pattern: Type-Level State Machine
Conditional types can model state transitions:
type NextState<Current extends string> =
Current extends "idle" ? "loading" :
Current extends "loading" ? "success" | "error" :
Current extends "error" ? "loading" :
Current extends "success" ? "idle" :
never;
type S1 = NextState<"idle">; // "loading"
type S2 = NextState<"loading">; // "success" | "error"
type S3 = NextState<"error">; // "loading"
Advanced Pattern: Type-Level Arithmetic
Conditional types can do arithmetic at the type level (we'll explore this fully in Chapter 15):
type Increment<N extends number> =
N extends 0 ? 1 :
N extends 1 ? 2 :
N extends 2 ? 3 :
// ... (limited, but demonstrates the concept)
never;
When NOT to Use Conditional Types
Conditional types are powerful but can make code hard to read. Guidelines:
- Use conditional types for type transformations that depend on input types
- Use function overloads for runtime behavior that depends on input types
- Use discriminated unions for modeling state
- Don't use conditional types when a simpler union or intersection would work
The best conditional type is the one you don't need to write because a simpler feature handles it.
Try This: Conditional Types
- Write an
IsArray<T>conditional type that returnstrueifTis an array,falseotherwise. - Write a
TypeName<T>that returns"string","number","boolean","function","array", or"object"based onT. - Write a
NonFunctionProperties<T>that extracts only the non-function properties from an object type. - Write a
DeepPartial<T>that recursively makes all properties optional. - Write a
Flatten<T>that recursively flattens nested arrays to their element type.
Time needed: 25 minutes.
What to notice: How distributive conditional types process unions member by member. How the never trick filters unions. How recursive conditional types process nested structures. How these patterns combine to create types that can analyze and transform any other type.
The Bridge
You now understand conditional types — the decision-making engine of TypeScript's type system. Combined with generics, mapped types, and infer, you have all the tools for type-level programming.
But how far can you push it? Can you do arithmetic at the type level? Can you parse strings into tokens? Can you build a type-level calculator?
Yes. Yes, you can. And that's the next chapter.
In the next chapter: type-level programming — recursive types, type-level arithmetic, building a type-level calculator, and understanding the limits (and practical uses) of TypeScript's Turing-complete type system.