TypeScript Conditional Types
Introduction
TypeScript conditional types are one of the most powerful features in TypeScript's type system, allowing you to create types that change based on conditions. They enable you to create dynamic types that respond to the properties of other types—similar to how you might use an if
statement in your regular code, but at the type level.
Conditional types follow this pattern:
type Result = SomeType extends OtherType ? TrueType : FalseType;
This might seem abstract at first, but once understood, conditional types become an invaluable tool for creating flexible, reusable type definitions.
Understanding the Basics
The extends Keyword
At the heart of conditional types is the extends
keyword, which acts as a condition check. The syntax resembles a ternary operator in JavaScript:
type CheckNumber<T> = T extends number ? 'Yes, it is a number' : 'No, it is not a number';
// Usage
type IsItANumber = CheckNumber<42>; // 'Yes, it is a number'
type IsItANumberString = CheckNumber<'42'>; // 'No, it is not a number'
In this example:
CheckNumber<T>
is a generic type that takes a type parameterT
- It checks whether
T extends number
(isT
a subtype ofnumber
?) - If true, the type becomes
'Yes, it is a number'
- If false, the type becomes
'No, it is not a number'
Conditional Types with Unions
When a conditional type is applied to a union type, it distributes over each member of the union:
type ToArray<Type> = Type extends any ? Type[] : never;
type StrOrNumArray = ToArray<string | number>;
// Equivalent to: string[] | number[]
This is known as the distributive property of conditional types. TypeScript applies the condition to each member of the union separately.
Advanced Conditional Types
Extracting Types with infer
The infer
keyword takes conditional types to another level by allowing you to extract type information from the condition:
type GetReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
// Usage
function greeting(): string {
return "Hello world!";
}
type GreetingReturnType = GetReturnType<typeof greeting>; // string
Here, infer R
captures the return type of the function as a new type variable R
, which is then used as the true branch of the conditional.
Predefined Conditional Types
TypeScript provides several built-in conditional types:
Extract<T, U>
- Extracts all members ofT
that are assignable toU
Exclude<T, U>
- Excludes all members ofT
that are assignable toU
NonNullable<T>
- Removesnull
andundefined
fromT
ReturnType<T>
- Gets the return type of a function typeParameters<T>
- Gets the parameter types of a function type as a tuple
// Extract
type OnlyNumbers = Extract<string | number | boolean, number>; // number
// Exclude
type NoNumbers = Exclude<string | number | boolean, number>; // string | boolean
// NonNullable
type NotNull = NonNullable<string | null | undefined>; // string
// ReturnType
type FuncReturn = ReturnType<() => number>; // number
// Parameters
type FuncParams = Parameters<(a: string, b: number) => void>; // [string, number]
Real-World Applications
Type-Safe Event Handlers
Conditional types help create type-safe event handlers based on event types:
type EventConfig = {
click: {
x: number;
y: number;
};
focus: {
id: string;
};
keydown: {
key: string;
shiftKey: boolean;
};
};
type EventHandler<E extends keyof EventConfig> = (data: EventConfig[E]) => void;
// Usage
const handleClick: EventHandler<'click'> = (data) => {
console.log(`Clicked at ${data.x}, ${data.y}`);
};
const handleKeydown: EventHandler<'keydown'> = (data) => {
console.log(`Key pressed: ${data.key}, shift: ${data.shiftKey}`);
};
Building a Type-Safe API Client
Conditional types can help build type-safe API clients:
interface ApiEndpoints {
'/users': {
get: {
response: { id: number; name: string }[];
};
post: {
body: { name: string; email: string };
response: { id: number; success: boolean };
};
};
'/posts': {
get: {
response: { id: number; title: string; content: string }[];
};
};
}
type ApiMethod = 'get' | 'post' | 'put' | 'delete';
type EndpointHasMethod<
Path extends keyof ApiEndpoints,
Method extends ApiMethod
> = Method extends keyof ApiEndpoints[Path] ? true : false;
type RequestBody<
Path extends keyof ApiEndpoints,
Method extends ApiMethod
> = EndpointHasMethod<Path, Method> extends true
? 'body' extends keyof ApiEndpoints[Path][Method]
? ApiEndpoints[Path][Method]['body']
: never
: never;
type ResponseData<
Path extends keyof ApiEndpoints,
Method extends ApiMethod
> = EndpointHasMethod<Path, Method> extends true
? 'response' extends keyof ApiEndpoints[Path][Method]
? ApiEndpoints[Path][Method]['response']
: never
: never;
// Simplified API client
function apiRequest<
Path extends keyof ApiEndpoints,
Method extends ApiMethod
>(
path: Path,
method: Method,
body?: RequestBody<Path, Method>
): Promise<ResponseData<Path, Method>> {
// Implementation would go here
return fetch(path, {
method,
body: body ? JSON.stringify(body) : undefined
}).then(res => res.json());
}
// Usage
async function example() {
// Correctly typed - body and response are properly inferred
const users = await apiRequest('/users', 'get');
// users is inferred as { id: number; name: string }[]
const newUser = await apiRequest('/users', 'post', {
name: 'John Doe',
email: '[email protected]'
});
// newUser is inferred as { id: number; success: boolean }
}
Filtering Object Properties by Type
Another practical use is filtering object properties based on their type:
type FilterByValueType<Obj, ValueType> = {
[Key in keyof Obj as Obj[Key] extends ValueType ? Key : never]: Obj[Key];
};
interface User {
id: number;
name: string;
isActive: boolean;
createdAt: Date;
tags: string[];
settings: { theme: string; notifications: boolean };
}
// Extract only string properties
type StringProperties = FilterByValueType<User, string>;
// { name: string }
// Extract only boolean properties
type BooleanProperties = FilterByValueType<User, boolean>;
// { isActive: boolean }
// Extract only object properties
type ObjectProperties = FilterByValueType<User, object>;
// { createdAt: Date; tags: string[]; settings: { theme: string; notifications: boolean } }
Understanding Mapped Types with Conditional Types
Conditional types become even more powerful when combined with mapped types:
type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
interface Product {
id: string;
name: string;
price: number;
stock: number;
description: string;
}
// Make price and stock optional
type UpdateProduct = Optional<Product, 'price' | 'stock'>;
// Usage
const updateProduct: UpdateProduct = {
id: '1',
name: 'Laptop',
description: 'A powerful laptop'
// price and stock are now optional
};
Summary
TypeScript conditional types are a powerful mechanism that allows you to create dynamic and flexible type definitions. They work like type-level if-statements, enabling you to:
- Create types that respond to the properties of other types
- Extract specific types using the
infer
keyword - Distribute over union types
- Build complex type utilities
By understanding and leveraging conditional types, you can create more type-safe, reusable code that can adapt to different situations and provide better developer experience.
Exercises
To reinforce your understanding of conditional types, try these exercises:
- Create a
FunctionPropertyNames<T>
type that extracts all property names of an object that are functions. - Implement a
DeepReadonly<T>
type that makes all properties of an object (and nested objects) readonly. - Create a conditional type
IsEmpty<T>
that checks if a type is{}
orunknown
orany
. - Build a
UnionToIntersection<U>
type that converts a union type to an intersection type.
Additional Resources
- TypeScript Handbook: Conditional Types
- TypeScript Deep Dive: Advanced Types
- TypeScript Utility Types Documentation
By mastering conditional types, you'll unlock powerful type manipulation capabilities and be able to create more flexible, reusable, and type-safe code.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)