Conditional Types
Overview
Conditional types provide if/else logic at the type level. They use the extends keyword to check type relationships and return different types based on the result.
Basic Syntax
type Conditional = SomeType extends OtherType ? TrueType : FalseType;
The condition checks if SomeType is assignable to OtherType.
Simple Examples
// Check if type is string
type IsString<T> = T extends string ? true : false;
type Test1 = IsString<string>; // true
type Test2 = IsString<number>; // false
type Test3 = IsString<"hello">; // true (literal extends string)
// Check type relationships
type Result1 = string extends string ? "yes" : "no"; // "yes"
type Result2 = string extends number ? "yes" : "no"; // "no"
type Result3 = "hello" extends string ? "yes" : "no"; // "yes"
Practical Example: Null Checking
type IsNullable<T> = null extends T ? true : false;
type Test1 = IsNullable<string | null>; // true
type Test2 = IsNullable<string>; // false
type Test3 = IsNullable<undefined>; // false (null !== undefined)
Conditional Types with Generics
// Return different types based on input
type TypeName<T> = T extends string
? "string"
: T extends number
? "number"
: T extends boolean
? "boolean"
: T extends undefined
? "undefined"
: T extends (...args: any[]) => any
? "function"
: "object";
type T1 = TypeName<string>; // "string"
type T2 = TypeName<number>; // "number"
type T3 = TypeName<() => void>; // "function"
type T4 = TypeName<{ a: 1 }>; // "object"
Distribution Over Unions
When a conditional type acts on a union, it distributes over each member:
type ToArray<T> = T extends any ? T[] : never;
type Result = ToArray<string | number>;
// Distributes to: ToArray<string> | ToArray<number>
// Result: string[] | number[]
// NOT: (string | number)[]
Preventing Distribution
Wrap in tuple to prevent distribution:
type ToArrayNonDistributive<T> = [T] extends [any] ? T[] : never;
type Result = ToArrayNonDistributive<string | number>;
// Result: (string | number)[]
Practical Use: Optional Parameters
interface BaseRouterConfig {
search?: string[];
}
type TupleToSearchParams<T extends string[]> = {
[K in T[number]]?: string;
};
// Only convert if search is defined and is a string array
type SearchParams<
TConfig extends BaseRouterConfig,
TRoute extends keyof TConfig,
> = TConfig[TRoute]["search"] extends string[]
? TupleToSearchParams<TConfig[TRoute]["search"]>
: undefined;
Using Conditionals in Function Arguments
const makeRouter = <TConfig extends Record<string, { search?: string[] }>>(
config: TConfig
) => {
return {
goTo: <TRoute extends keyof TConfig>(
route: TRoute,
// Only allow search params if route has search defined
search?: TConfig[TRoute]["search"] extends string[]
? { [K in TConfig[TRoute]["search"][number]]?: string }
: never
) => {
// Implementation
},
};
};
const router = makeRouter({
"/": {},
"/search": { search: ["query", "page"] },
});
router.goTo("/"); // No search param allowed
router.goTo("/search", { query: "test", page: "1" }); // Search params required
Filtering with Conditionals
Use never to filter out types:
type ExtractStrings<T> = T extends string ? T : never;
type Mixed = "a" | "b" | 1 | 2 | true;
type OnlyStrings = ExtractStrings<Mixed>; // "a" | "b"
This is how Extract and Exclude utilities work:
// Built-in utility implementations
type Extract<T, U> = T extends U ? T : never;
type Exclude<T, U> = T extends U ? never : T;
Nested Conditionals
type DeepReadonly<T> = T extends (...args: any[]) => any
? T
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
interface User {
name: string;
address: {
city: string;
country: string;
};
greet: () => void;
}
type ReadonlyUser = DeepReadonly<User>;
// All properties including nested ones are readonly
// Functions remain unchanged
Checking for Empty Types
// Check if array type is empty
type IsEmptyArray<T extends any[]> = T extends []
? true
: T extends [any, ...any[]]
? false
: boolean; // Unknown length arrays
type Test1 = IsEmptyArray<[]>; // true
type Test2 = IsEmptyArray<[1]>; // false
type Test3 = IsEmptyArray<string[]>; // boolean (unknown at compile time)
Non-Empty Array Check
// Ensure array has at least one element
type NonEmptyArray<T extends any[]> = T extends [infer First, ...infer Rest]
? [First, ...Rest]
: never;
type Config = {
fields: ["name", "email"]; // Non-empty
};
// Use in conditional
type HasFields<T extends { fields?: string[] }> = T["fields"] extends [
string,
...string[],
]
? true
: false;
Common Patterns
Unwrap Promise Type
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type Test1 = UnwrapPromise<Promise<string>>; // string
type Test2 = UnwrapPromise<string>; // string (passthrough)
Unwrap Array Type
type UnwrapArray<T> = T extends (infer U)[] ? U : T;
type Test1 = UnwrapArray<string[]>; // string
type Test2 = UnwrapArray<number>; // number (passthrough)
Make All Properties Nullable
type Nullable<T> = T extends object
? { [K in keyof T]: T[K] | null }
: T | null;
When to Use Conditional Types
- Type transformations: Different output types based on input
- Filtering unions: Extract or exclude certain types
- Optional type features: Enable features based on configuration
- Type guards: Return different types based on conditions
- Recursive types: Base cases in recursive type definitions
Common Pitfalls
Forgetting Distribution
type WrongIsArray<T> = T extends any[] ? true : false;
type Test = WrongIsArray<string | number[]>; // boolean (distributes!)
// If you want to check the entire union:
type CorrectIsArray<T> = [T] extends [any[]] ? true : false;
type Test2 = CorrectIsArray<string | number[]>; // false
Over-complicated Conditions
Sometimes union types or overloads are simpler:
// Over-complicated
type ProcessResult<T> = T extends string
? { type: "string"; value: string }
: T extends number
? { type: "number"; value: number }
: never;
// Simpler with discriminated union
type Result =
| { type: "string"; value: string }
| { type: "number"; value: number };
Forgetting the False Branch
// Always provide a sensible false branch
type ExtractName<T> = T extends { name: infer N } ? N : never;
// Consider: what happens when T doesn't have name?
type Test = ExtractName<{ age: number }>; // never