|$ curl https://forge-ai.dev/api/markdown?path=docs/typescript/advanced
$cat docs/typescript-—-conditional-types.md
updated Recently·14 min read·published

TypeScript — Conditional Types

TypeScriptAdvanced
Introduction

Conditional types are TypeScript's most powerful type-level feature. They let you express type relationships that depend on conditions — essentially, type-level if/else. Combined with the infer keyword for pattern matching and recursion for iteration, conditional types enable transformations that would otherwise require massive manual type definitions.

Every built-in utility type (ReturnType, Exclude, NonNullable, etc.) is implemented using conditional types. Understanding them means you can not only use the built-ins more effectively but also create your own custom utility types.

📝

note

Conditional types have no runtime cost. They are evaluated entirely by the type checker and erased during compilation. They are pure type-level computation.
Basic Conditional Syntax

The syntax T extends U ? X : Y reads as: if type T is assignable to type U, then X, otherwise Y. This is a ternary expression at the type level.

basic-conditional.ts
TypeScript
1// Simple conditional — check if a type is a string
2type IsString<T> = T extends string ? true : false;
3
4type A = IsString<"hello">; // true
5type B = IsString<42>; // false
6type C = IsString<string>; // true
7type D = IsString<any>; // boolean — distributes over any
8type E = IsString<never>; // never — conditional on never is never
9
10// Chained conditionals — type-level else if
11type TypeName<T> =
12 T extends string ? "string" :
13 T extends number ? "number" :
14 T extends boolean ? "boolean" :
15 T extends undefined ? "undefined" :
16 T extends null ? "null" :
17 T extends bigint ? "bigint" :
18 T extends symbol ? "symbol" :
19 T extends Function ? "function" :
20 "object";
21
22type T1 = TypeName<string>; // "string"
23type T2 = TypeName<42>; // "number"
24type T3 = TypeName<true>; // "boolean"
25type T4 = TypeName<{ a: number }>; // "object"
26type T5 = TypeName<() => void>; // "function"
27
28// Conditional with generic constraint
29function getLength<T extends { length: number }>(value: T): number {
30 return value.length;
31}
32
33// Conditional return type based on input
34type IsArray<T> = T extends any[] ? true : false;
35
36function processValue<T>(value: T): IsArray<T> extends true ? T[number] : T {
37 if (Array.isArray(value)) {
38 return value[0] as any;
39 }
40 return value as any;
41}
42
43// Filtering with conditionals — keeps only string keys
44type StringKeys<T> = {
45 [K in keyof T]: T[K] extends string ? K : never;
46}[keyof T];
47
48interface Person {
49 name: string;
50 age: number;
51 email: string;
52 id: number;
53}
54
55type PersonStringKeys = StringKeys<Person>;
56// "name" | "email"

info

Think of conditional types as type-level functions. The condition T extends U checks assignability, not equality. "hello" extends string is true, but string extends "hello" is false.
Distributive Conditional Types

When a conditional type acts on a naked type parameter (a bare T, not T[] or Promise<T>), it distributes over union types. This means the conditional is applied to each union member individually, and the results are unioned back together.

distributive.ts
TypeScript
1// Distribution — conditional applies to each union member
2type ToArray<T> = T extends any ? T[] : never;
3
4type Result = ToArray<string | number>;
5// string[] | number[] — NOT (string | number)[]
6// The conditional is applied to each member separately:
7// ToArray<string> = string[]
8// ToArray<number> = number[]
9// Result = string[] | number[]
10
11// Without distribution — wrap T in a container
12type ToArrayNonDist<T> = [T] extends [any] ? T[] : never;
13
14type Result2 = ToArrayNonDist<string | number>;
15// (string | number)[] — single array type, not distributed
16
17// Exclude — built-in using distribution
18type MyExclude<T, U> = T extends U ? never : T;
19
20type Test = MyExclude<string | number | boolean, string | number>;
21// Distributive: MyExclude<string, ...> | MyExclude<number, ...> | MyExclude<boolean, ...>
22// never | never | boolean
23// = boolean
24
25// Extract — built-in using distribution
26type MyExtract<T, U> = T extends U ? T : never;
27
28type Test2 = MyExtract<string | number | boolean, string | number>;
29// string | number
30
31// Distribution with Filter — keep matching types
32type FilterString<T> = T extends string ? T : never;
33
34type StringsOnly = FilterString<"a" | 1 | "b" | 2 | "c">;
35// "a" | "b" | "c"
36
37// Never and distribution — never is the empty union
38type DistributeNever = FilterString<never>;
39// never — no members to distribute over
40
41// Practical: remove null/undefined from a type
42type MyNonNullable<T> = T extends null | undefined ? never : T;
43
44type NotNull = MyNonNullable<string | null | undefined>;
45// string

info

Distribution happens only with bare type parameters. Wrap T in [T] or a tuple to prevent distribution. This trick is essential when you want to check the entire union rather than each member.
The infer Keyword

infer lets you declare a type variable within the extends clause of a conditional type. It enables pattern matching on types — extract the element type of an array, the return type of a function, or the resolved type of a promise.

infer-keyword.ts
TypeScript
1// Extract element type from an array
2type ElementType<T> = T extends (infer U)[] ? U : never;
3
4type E1 = ElementType<string[]>; // string
5type E2 = ElementType<number[][]>; // number[] (one level only)
6type E3 = ElementType<"not-array">; // never
7
8// Extract return type of a function
9type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
10
11type Fn = () => string;
12type R1 = MyReturnType<Fn>; // string
13
14type Fn2 = (a: number, b: string) => Promise<boolean>;
15type R2 = MyReturnType<Fn2>; // Promise<boolean>
16
17// Extract parameter types as a tuple
18type MyParameters<T> = T extends (...args: infer P) => any ? P : never;
19
20type Params = MyParameters<(name: string, age: number) => void>;
21// [name: string, age: number]
22
23// Extract constructor parameters
24type MyConstructorParameters<T> = T extends abstract new (...args: infer P) => any ? P : never;
25
26class Database {
27 constructor(host: string, port: number) {}
28}
29
30type DBParams = MyConstructorParameters<typeof Database>;
31// [host: string, port: number]
32
33// Extract instance type from constructor
34type MyInstanceType<T> = T extends abstract new (...args: any[]) => infer I ? I : never;
35
36type DBInstance = MyInstanceType<typeof Database>;
37// Database
38
39// Multiple infer in one conditional
40type SwapPromiseAndArray<T> =
41 T extends Promise<infer U>
42 ? U extends (infer V)[]
43 ? Promise<V[]>
44 : never
45 : never;
46
47type Swapped = SwapPromiseAndArray<Promise<number[]>>;
48// Promise<number>[] — wait, that's not right...
49// Actually: T = Promise<number[]>, U = number[], V = number
50// Result: Promise<number[]>
51// Let's fix: SwapPromiseAndArray unwraps Promise then wraps array
52
53// Infer with function overloads — infer picks the last overload
54function overloaded(x: string): string;
55function overloaded(x: number): number;
56function overloaded(x: string | number): string | number {
57 return x;
58}
59
60type OverloadedReturn = MyReturnType<typeof overloaded>;
61// string | number — multiple call signatures unioned
infer-practical.ts
TypeScript
1// Practical: infer from Promise
2type Unpromise<T> = T extends Promise<infer U> ? U : T;
3
4type P1 = Unpromise<Promise<string>>; // string
5type P2 = Unpromise<Promise<Promise<number>>>; // Promise<number> — not recursive!
6type P3 = Unpromise<number>; // number
7
8// Recursive version (see later section)
9type DeepUnpromise<T> = T extends Promise<infer U> ? DeepUnpromise<U> : T;
10type P4 = DeepUnpromise<Promise<Promise<number>>>; // number
11
12// Infer from template literal types
13type FirstChar<T extends string> = T extends `${infer F}${infer Rest}` ? F : never;
14
15type FC1 = FirstChar<"hello">; // "h"
16type FC2 = FirstChar<"a">; // "a"
17type FC3 = FirstChar<"">; // never — doesn't match pattern
18
19// Infer from function with specific this parameter
20type ThisType<T> = T extends (this: infer This, ...args: any[]) => any ? This : never;
21
22function handler(this: HTMLElement, e: Event): void {}
23type HThis = ThisType<typeof handler>; // HTMLElement
24
25// Practical: infer value type from a Map
26type MapValue<T> = T extends Map<any, infer V> ? V : never;
27
28type MV = MapValue<Map<string, number>>; // number
29
30// Infer from Record
31type RecordValue<T> = T extends Record<any, infer V> ? V : never;
32
33type RV = RecordValue<Record<string, boolean>>; // boolean

best practice

infer is the most versatile tool in advanced TypeScript. Use it to extract inner types from generic containers (Promise, Array, Map), function signatures, and template literal patterns. Every time you find yourself manually annotating a type that could be inferred, reach for infer.
Recursive Conditional Types

Conditional types can reference themselves, enabling iteration over nested types. TypeScript 4.1+ has improved support for recursive types with better performance and depth limits.

recursive.ts
TypeScript
1// Deep flatten — unwrap nested promises
2type DeepUnwrap<T> = T extends Promise<infer U> ? DeepUnwrap<U> : T;
3
4type Deep = DeepUnwrap<Promise<Promise<Promise<string>>>>;
5// string
6
7// Deep readonly — make all nested properties readonly
8type DeepReadonly<T> = {
9 readonly [P in keyof T]: T[P] extends object
10 ? T[P] extends Function
11 ? T[P]
12 : DeepReadonly<T[P]>
13 : T[P];
14};
15
16interface Config {
17 database: { host: string; port: number };
18 cache: { ttl: number; options: { prefix: string } };
19}
20
21type FrozenConfig = DeepReadonly<Config>;
22// All levels are readonly
23
24// Deep partial — make all nested properties optional
25type DeepPartial<T> = {
26 [P in keyof T]?: T[P] extends object
27 ? DeepPartial<T[P]>
28 : T[P];
29};
30
31// Deep required — inverse of deep partial
32type DeepRequired<T> = {
33 [P in keyof T]-?: T[P] extends object
34 ? DeepRequired<T[P]>
35 : T[P];
36};
37
38// Deep non-nullable — remove null/undefined from all levels
39type DeepNonNullable<T> = {
40 [P in keyof T]: T[P] extends object
41 ? DeepNonNullable<NonNullable<T[P]>>
42 : NonNullable<T[P]>;
43};
44
45// Flatten array one level
46type Flatten<T> = T extends any[] ? T[number] : T;
47
48// Deep flatten arrays
49type DeepFlatten<T> = T extends (infer U)[]
50 ? DeepFlatten<U>
51 : T;
52
53type Nested = [[[1, 2], [3]], [[4, 5]]];
54type Flat = DeepFlatten<Nested>;
55// 1 | 2 | 3 | 4 | 5
56
57// Recursive type for JSON values
58type JSONValue =
59 | string
60 | number
61 | boolean
62 | null
63 | JSONValue[]
64 | { [key: string]: JSONValue };
65
66// Primitives detection
67type IsPrimitive<T> = T extends string | number | boolean | null | undefined | symbol | bigint
68 ? true
69 : false;

warning

Recursive types have a depth limit (typically ~50 levels). Very deep recursion may cause compiler performance issues or the error Type instantiation is excessively deep and possibly infinite. Structure your recursive types to terminate predictably.
Real-World Patterns

These patterns combine conditional types, infer, mapped types, and recursion to solve common real-world typing challenges.

real-world-patterns.ts
TypeScript
1// Pattern 1: FunctionPropertyNames — get keys that are functions
2type FunctionPropertyNames<T> = {
3 [K in keyof T]: T[K] extends Function ? K : never;
4}[keyof T];
5
6interface Service {
7 start(): void;
8 stop(): void;
9 getStatus(): string;
10 name: string;
11 version: number;
12}
13
14type ServiceMethods = FunctionPropertyNames<Service>;
15// "start" | "stop" | "getStatus"
16
17// Pattern 2: FunctionProperties — get only function members
18type FunctionProperties<T> = Pick<T, FunctionPropertyNames<T>>;
19// { start: () => void; stop: () => void; getStatus: () => string }
20
21// Pattern 3: NonFunctionPropertyNames — inverse
22type NonFunctionPropertyNames<T> = {
23 [K in keyof T]: T[K] extends Function ? never : K;
24}[keyof T];
25
26type NonFunctionProps<T> = Pick<T, NonFunctionPropertyNames<T>>;
27// { name: string; version: number }
28
29// Pattern 4: OptionalKeys — get keys of optional properties
30type OptionalKeys<T> = {
31 [K in keyof T]-?: {} extends Pick<T, K> ? K : never;
32}[keyof T];
33
34interface Options {
35 required: string;
36 optional?: number;
37 alsoOptional?: boolean;
38}
39
40type Optional = OptionalKeys<Options>;
41// "optional" | "alsoOptional"
42
43// Pattern 5: RequiredKeys — get keys of required properties
44type RequiredKeys<T> = Exclude<keyof T, OptionalKeys<T>>;
45type Required = RequiredKeys<Options>;
46// "required"
47
48// Pattern 6: UnionToIntersection — convert union to intersection
49type UnionToIntersection<U> = (
50 U extends any ? (k: U) => void : never
51) extends (k: infer I) => void
52 ? I
53 : never;
54
55type Union = { a: string } | { b: number };
56type Intersection = UnionToIntersection<Union>;
57// { a: string } & { b: number }
58
59// Pattern 7: Paths — get all dot-path keys of a nested object
60type Paths<T, Prefix extends string = ""> = {
61 [K in keyof T]: T[K] extends object
62 ? Paths<T[K], `${Prefix}${string & K}.`>
63 : `${Prefix}${string & K}`;
64}[keyof T];
65
66type NestedConfig = {
67 app: { name: string; port: number };
68 db: { host: string; port: number };
69};
70
71type ConfigPaths = Paths<NestedConfig>;
72// "app.name" | "app.port" | "db.host" | "db.port"

best practice

These patterns are the building blocks of production TypeScript libraries. Study them to understand how libraries like Prisma, Zod, and tRPC generate complex types from simple input. Start by reading the source of type-fest — it's the standard library of TypeScript utility types.
Conditional Type Precedence

Conditional types follow specific evaluation rules. Understanding precedence is critical when combining multiple conditions, mapped types, and infer in complex expressions.

precedence.ts
TypeScript
1// 1. Distributive conditionals evaluate per union member
2type Dist<T> = T extends string ? "S" : "N";
3type D1 = Dist<string | number>; // "S" | "N"
4
5// 2. Naked type parameter vs wrapped
6type Naked<T> = T extends string ? "yes" : "no";
7type Wrapped<T> = [T] extends [string] ? "yes" : "no";
8
9type N1 = Naked<string | number>; // "yes" | "no" — distributed
10type W1 = Wrapped<string | number>; // "no" — checked as a unit
11
12// 3. any and boolean distribute
13type AnyCheck<T> = T extends string ? true : false;
14type AC1 = AnyCheck<any>; // boolean — distributes over any
15type AC2 = AnyCheck<boolean>; // boolean — true | false
16
17// 4. never in conditionals
18type NeverCheck<T> = T extends string ? "yes" : "no";
19type NC1 = NeverCheck<never>; // never — empty union
20
21// 5. Multiple infer — last match wins
22type MultiInfer<T> = T extends { a: infer A; b: infer A } ? A : never;
23type MI1 = MultiInfer<{ a: string; b: number }>;
24// string | number — both inferred and unioned
25
26// 6. Conditional in mapped types
27type MakeOptional<T> = {
28 [K in keyof T]: T[K] extends Function ? T[K] : T[K] | undefined;
29};
30
31// 7. Order of conditions matters — first match wins
32type OrderMatters<T> =
33 T extends string ? "string" :
34 T extends any ? "any-match" :
35 never;
36
37type O1 = OrderMatters<"hello">; // "string" — matched first
38type O2 = OrderMatters<42>; // "any-match" — caught by 'any'
39
40// 8. Conditional types are deferred for unresolved generics
41function deferred<T>(value: T): T extends string ? "S" : "N" {
42 throw new Error();
43}
44
45// Inside function body, T is unresolved — conditional is deferred
46// TypeScript cannot evaluate until T is concrete at call site

info

When debugging complex conditional types, test each branch in isolation with concrete types. Use type Test = YourType<"concrete"> and hover to inspect the result. The Instantiation type is excessively deep error often means a missing base case in a recursive type.
Best Practices

1. Prefer built-in utility types over custom conditional types when possible — they are optimized, tested, and well-understood by other developers.

2. Use [T] extends [U] to prevent distribution when you want to check the full union as a single type.

3. Keep recursive conditional types shallow. If you exceed TypeScript's depth limit, reconsider whether the type transformation is necessary at the type level.

4. Use infer to extract types rather than manually constructing them — it produces more maintainable and self-adjusting type definitions.

5. Test conditional types at the type level with assertion helpers: type Assert<A, B> = [A] extends [B] ? ([B] extends [A] ? true : never) : never.

6. Avoid conditional types in function return type positions when the condition depends on a generic parameter — TypeScript cannot evaluate deferred conditionals inside the function body.

7. Name your conditional types meaningfully. IsString<T> is clearer than a deeply nested inline conditional.

8. Use the type-fest library for battle-tested utility types. It's maintained by the community and follows TypeScript best practices.

$Blueprint — Engineering Documentation·Section ID: TS-COND·Revision: 1.0