TypeScript — Conditional Types
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
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.
| 1 | // Simple conditional — check if a type is a string |
| 2 | type IsString<T> = T extends string ? true : false; |
| 3 | |
| 4 | type A = IsString<"hello">; // true |
| 5 | type B = IsString<42>; // false |
| 6 | type C = IsString<string>; // true |
| 7 | type D = IsString<any>; // boolean — distributes over any |
| 8 | type E = IsString<never>; // never — conditional on never is never |
| 9 | |
| 10 | // Chained conditionals — type-level else if |
| 11 | type 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 | |
| 22 | type T1 = TypeName<string>; // "string" |
| 23 | type T2 = TypeName<42>; // "number" |
| 24 | type T3 = TypeName<true>; // "boolean" |
| 25 | type T4 = TypeName<{ a: number }>; // "object" |
| 26 | type T5 = TypeName<() => void>; // "function" |
| 27 | |
| 28 | // Conditional with generic constraint |
| 29 | function getLength<T extends { length: number }>(value: T): number { |
| 30 | return value.length; |
| 31 | } |
| 32 | |
| 33 | // Conditional return type based on input |
| 34 | type IsArray<T> = T extends any[] ? true : false; |
| 35 | |
| 36 | function 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 |
| 44 | type StringKeys<T> = { |
| 45 | [K in keyof T]: T[K] extends string ? K : never; |
| 46 | }[keyof T]; |
| 47 | |
| 48 | interface Person { |
| 49 | name: string; |
| 50 | age: number; |
| 51 | email: string; |
| 52 | id: number; |
| 53 | } |
| 54 | |
| 55 | type PersonStringKeys = StringKeys<Person>; |
| 56 | // "name" | "email" |
info
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.
| 1 | // Distribution — conditional applies to each union member |
| 2 | type ToArray<T> = T extends any ? T[] : never; |
| 3 | |
| 4 | type 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 |
| 12 | type ToArrayNonDist<T> = [T] extends [any] ? T[] : never; |
| 13 | |
| 14 | type Result2 = ToArrayNonDist<string | number>; |
| 15 | // (string | number)[] — single array type, not distributed |
| 16 | |
| 17 | // Exclude — built-in using distribution |
| 18 | type MyExclude<T, U> = T extends U ? never : T; |
| 19 | |
| 20 | type 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 |
| 26 | type MyExtract<T, U> = T extends U ? T : never; |
| 27 | |
| 28 | type Test2 = MyExtract<string | number | boolean, string | number>; |
| 29 | // string | number |
| 30 | |
| 31 | // Distribution with Filter — keep matching types |
| 32 | type FilterString<T> = T extends string ? T : never; |
| 33 | |
| 34 | type StringsOnly = FilterString<"a" | 1 | "b" | 2 | "c">; |
| 35 | // "a" | "b" | "c" |
| 36 | |
| 37 | // Never and distribution — never is the empty union |
| 38 | type DistributeNever = FilterString<never>; |
| 39 | // never — no members to distribute over |
| 40 | |
| 41 | // Practical: remove null/undefined from a type |
| 42 | type MyNonNullable<T> = T extends null | undefined ? never : T; |
| 43 | |
| 44 | type NotNull = MyNonNullable<string | null | undefined>; |
| 45 | // string |
info
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.
| 1 | // Extract element type from an array |
| 2 | type ElementType<T> = T extends (infer U)[] ? U : never; |
| 3 | |
| 4 | type E1 = ElementType<string[]>; // string |
| 5 | type E2 = ElementType<number[][]>; // number[] (one level only) |
| 6 | type E3 = ElementType<"not-array">; // never |
| 7 | |
| 8 | // Extract return type of a function |
| 9 | type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never; |
| 10 | |
| 11 | type Fn = () => string; |
| 12 | type R1 = MyReturnType<Fn>; // string |
| 13 | |
| 14 | type Fn2 = (a: number, b: string) => Promise<boolean>; |
| 15 | type R2 = MyReturnType<Fn2>; // Promise<boolean> |
| 16 | |
| 17 | // Extract parameter types as a tuple |
| 18 | type MyParameters<T> = T extends (...args: infer P) => any ? P : never; |
| 19 | |
| 20 | type Params = MyParameters<(name: string, age: number) => void>; |
| 21 | // [name: string, age: number] |
| 22 | |
| 23 | // Extract constructor parameters |
| 24 | type MyConstructorParameters<T> = T extends abstract new (...args: infer P) => any ? P : never; |
| 25 | |
| 26 | class Database { |
| 27 | constructor(host: string, port: number) {} |
| 28 | } |
| 29 | |
| 30 | type DBParams = MyConstructorParameters<typeof Database>; |
| 31 | // [host: string, port: number] |
| 32 | |
| 33 | // Extract instance type from constructor |
| 34 | type MyInstanceType<T> = T extends abstract new (...args: any[]) => infer I ? I : never; |
| 35 | |
| 36 | type DBInstance = MyInstanceType<typeof Database>; |
| 37 | // Database |
| 38 | |
| 39 | // Multiple infer in one conditional |
| 40 | type SwapPromiseAndArray<T> = |
| 41 | T extends Promise<infer U> |
| 42 | ? U extends (infer V)[] |
| 43 | ? Promise<V[]> |
| 44 | : never |
| 45 | : never; |
| 46 | |
| 47 | type 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 |
| 54 | function overloaded(x: string): string; |
| 55 | function overloaded(x: number): number; |
| 56 | function overloaded(x: string | number): string | number { |
| 57 | return x; |
| 58 | } |
| 59 | |
| 60 | type OverloadedReturn = MyReturnType<typeof overloaded>; |
| 61 | // string | number — multiple call signatures unioned |
| 1 | // Practical: infer from Promise |
| 2 | type Unpromise<T> = T extends Promise<infer U> ? U : T; |
| 3 | |
| 4 | type P1 = Unpromise<Promise<string>>; // string |
| 5 | type P2 = Unpromise<Promise<Promise<number>>>; // Promise<number> — not recursive! |
| 6 | type P3 = Unpromise<number>; // number |
| 7 | |
| 8 | // Recursive version (see later section) |
| 9 | type DeepUnpromise<T> = T extends Promise<infer U> ? DeepUnpromise<U> : T; |
| 10 | type P4 = DeepUnpromise<Promise<Promise<number>>>; // number |
| 11 | |
| 12 | // Infer from template literal types |
| 13 | type FirstChar<T extends string> = T extends `${infer F}${infer Rest}` ? F : never; |
| 14 | |
| 15 | type FC1 = FirstChar<"hello">; // "h" |
| 16 | type FC2 = FirstChar<"a">; // "a" |
| 17 | type FC3 = FirstChar<"">; // never — doesn't match pattern |
| 18 | |
| 19 | // Infer from function with specific this parameter |
| 20 | type ThisType<T> = T extends (this: infer This, ...args: any[]) => any ? This : never; |
| 21 | |
| 22 | function handler(this: HTMLElement, e: Event): void {} |
| 23 | type HThis = ThisType<typeof handler>; // HTMLElement |
| 24 | |
| 25 | // Practical: infer value type from a Map |
| 26 | type MapValue<T> = T extends Map<any, infer V> ? V : never; |
| 27 | |
| 28 | type MV = MapValue<Map<string, number>>; // number |
| 29 | |
| 30 | // Infer from Record |
| 31 | type RecordValue<T> = T extends Record<any, infer V> ? V : never; |
| 32 | |
| 33 | type RV = RecordValue<Record<string, boolean>>; // boolean |
best practice
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.
| 1 | // Deep flatten — unwrap nested promises |
| 2 | type DeepUnwrap<T> = T extends Promise<infer U> ? DeepUnwrap<U> : T; |
| 3 | |
| 4 | type Deep = DeepUnwrap<Promise<Promise<Promise<string>>>>; |
| 5 | // string |
| 6 | |
| 7 | // Deep readonly — make all nested properties readonly |
| 8 | type 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 | |
| 16 | interface Config { |
| 17 | database: { host: string; port: number }; |
| 18 | cache: { ttl: number; options: { prefix: string } }; |
| 19 | } |
| 20 | |
| 21 | type FrozenConfig = DeepReadonly<Config>; |
| 22 | // All levels are readonly |
| 23 | |
| 24 | // Deep partial — make all nested properties optional |
| 25 | type 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 |
| 32 | type 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 |
| 39 | type 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 |
| 46 | type Flatten<T> = T extends any[] ? T[number] : T; |
| 47 | |
| 48 | // Deep flatten arrays |
| 49 | type DeepFlatten<T> = T extends (infer U)[] |
| 50 | ? DeepFlatten<U> |
| 51 | : T; |
| 52 | |
| 53 | type Nested = [[[1, 2], [3]], [[4, 5]]]; |
| 54 | type Flat = DeepFlatten<Nested>; |
| 55 | // 1 | 2 | 3 | 4 | 5 |
| 56 | |
| 57 | // Recursive type for JSON values |
| 58 | type JSONValue = |
| 59 | | string |
| 60 | | number |
| 61 | | boolean |
| 62 | | null |
| 63 | | JSONValue[] |
| 64 | | { [key: string]: JSONValue }; |
| 65 | |
| 66 | // Primitives detection |
| 67 | type IsPrimitive<T> = T extends string | number | boolean | null | undefined | symbol | bigint |
| 68 | ? true |
| 69 | : false; |
warning
These patterns combine conditional types, infer, mapped types, and recursion to solve common real-world typing challenges.
| 1 | // Pattern 1: FunctionPropertyNames — get keys that are functions |
| 2 | type FunctionPropertyNames<T> = { |
| 3 | [K in keyof T]: T[K] extends Function ? K : never; |
| 4 | }[keyof T]; |
| 5 | |
| 6 | interface Service { |
| 7 | start(): void; |
| 8 | stop(): void; |
| 9 | getStatus(): string; |
| 10 | name: string; |
| 11 | version: number; |
| 12 | } |
| 13 | |
| 14 | type ServiceMethods = FunctionPropertyNames<Service>; |
| 15 | // "start" | "stop" | "getStatus" |
| 16 | |
| 17 | // Pattern 2: FunctionProperties — get only function members |
| 18 | type FunctionProperties<T> = Pick<T, FunctionPropertyNames<T>>; |
| 19 | // { start: () => void; stop: () => void; getStatus: () => string } |
| 20 | |
| 21 | // Pattern 3: NonFunctionPropertyNames — inverse |
| 22 | type NonFunctionPropertyNames<T> = { |
| 23 | [K in keyof T]: T[K] extends Function ? never : K; |
| 24 | }[keyof T]; |
| 25 | |
| 26 | type NonFunctionProps<T> = Pick<T, NonFunctionPropertyNames<T>>; |
| 27 | // { name: string; version: number } |
| 28 | |
| 29 | // Pattern 4: OptionalKeys — get keys of optional properties |
| 30 | type OptionalKeys<T> = { |
| 31 | [K in keyof T]-?: {} extends Pick<T, K> ? K : never; |
| 32 | }[keyof T]; |
| 33 | |
| 34 | interface Options { |
| 35 | required: string; |
| 36 | optional?: number; |
| 37 | alsoOptional?: boolean; |
| 38 | } |
| 39 | |
| 40 | type Optional = OptionalKeys<Options>; |
| 41 | // "optional" | "alsoOptional" |
| 42 | |
| 43 | // Pattern 5: RequiredKeys — get keys of required properties |
| 44 | type RequiredKeys<T> = Exclude<keyof T, OptionalKeys<T>>; |
| 45 | type Required = RequiredKeys<Options>; |
| 46 | // "required" |
| 47 | |
| 48 | // Pattern 6: UnionToIntersection — convert union to intersection |
| 49 | type UnionToIntersection<U> = ( |
| 50 | U extends any ? (k: U) => void : never |
| 51 | ) extends (k: infer I) => void |
| 52 | ? I |
| 53 | : never; |
| 54 | |
| 55 | type Union = { a: string } | { b: number }; |
| 56 | type Intersection = UnionToIntersection<Union>; |
| 57 | // { a: string } & { b: number } |
| 58 | |
| 59 | // Pattern 7: Paths — get all dot-path keys of a nested object |
| 60 | type 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 | |
| 66 | type NestedConfig = { |
| 67 | app: { name: string; port: number }; |
| 68 | db: { host: string; port: number }; |
| 69 | }; |
| 70 | |
| 71 | type ConfigPaths = Paths<NestedConfig>; |
| 72 | // "app.name" | "app.port" | "db.host" | "db.port" |
best practice
Conditional types follow specific evaluation rules. Understanding precedence is critical when combining multiple conditions, mapped types, and infer in complex expressions.
| 1 | // 1. Distributive conditionals evaluate per union member |
| 2 | type Dist<T> = T extends string ? "S" : "N"; |
| 3 | type D1 = Dist<string | number>; // "S" | "N" |
| 4 | |
| 5 | // 2. Naked type parameter vs wrapped |
| 6 | type Naked<T> = T extends string ? "yes" : "no"; |
| 7 | type Wrapped<T> = [T] extends [string] ? "yes" : "no"; |
| 8 | |
| 9 | type N1 = Naked<string | number>; // "yes" | "no" — distributed |
| 10 | type W1 = Wrapped<string | number>; // "no" — checked as a unit |
| 11 | |
| 12 | // 3. any and boolean distribute |
| 13 | type AnyCheck<T> = T extends string ? true : false; |
| 14 | type AC1 = AnyCheck<any>; // boolean — distributes over any |
| 15 | type AC2 = AnyCheck<boolean>; // boolean — true | false |
| 16 | |
| 17 | // 4. never in conditionals |
| 18 | type NeverCheck<T> = T extends string ? "yes" : "no"; |
| 19 | type NC1 = NeverCheck<never>; // never — empty union |
| 20 | |
| 21 | // 5. Multiple infer — last match wins |
| 22 | type MultiInfer<T> = T extends { a: infer A; b: infer A } ? A : never; |
| 23 | type MI1 = MultiInfer<{ a: string; b: number }>; |
| 24 | // string | number — both inferred and unioned |
| 25 | |
| 26 | // 6. Conditional in mapped types |
| 27 | type 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 |
| 32 | type OrderMatters<T> = |
| 33 | T extends string ? "string" : |
| 34 | T extends any ? "any-match" : |
| 35 | never; |
| 36 | |
| 37 | type O1 = OrderMatters<"hello">; // "string" — matched first |
| 38 | type O2 = OrderMatters<42>; // "any-match" — caught by 'any' |
| 39 | |
| 40 | // 8. Conditional types are deferred for unresolved generics |
| 41 | function 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
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.