
Mastering Conditional Types in TypeScript
Use conditional types to encode complex type logic—enabling safer, DRY, and expressive APIs at scale.
Why Conditional Types Matter at Scale
As your TypeScript codebase grows, so does the need for abstractions that are both expressive and type-safe. Conditional types unlock a form of type-level "if/else" logic, allowing you to build smarter, more maintainable utilities:
type IsString<T> = T extends string ? true : false;
type A = IsString<"foo">; // true
type B = IsString<42>; // false
This simple pattern underpins a wide range of type utilities—from filtering object properties to transforming deeply nested structures—while preserving full type safety.
⸻
Deep Dive: Building a DeepNullable
Suppose you work with a JSON-based API where any property might be null. You want a utility that makes every property in a deeply nested object nullable.
Example Input
interface User {
id: number;
profile: {
name: string;
preferences: {
theme: "light" | "dark";
notifications: boolean;
};
};
}
- Naïve Approach (Shallow)
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};
type ShallowNullableUser = Nullable<User>;
// ❌ `profile.preferences.theme` is still non-nullable
- Recursive Conditional Type
type DeepNullable<T> = T extends object
? { [K in keyof T]: DeepNullable<T[K]> | null }
: T | null;
type NullableUser = DeepNullable<User>;
This produces:
{
id: number | null;
profile: {
name: string | null;
preferences: {
theme: "light" | "dark" | null;
notifications: boolean | null;
} | null;
} | null;
}
How It Works 1. T extends object Checks whether the value is an object (note: this includes arrays and functions). 2. Mapped type Iterates over each key K in T. 3. Recursive application Applies DeepNullable to each property. 4. Base case If T is not an object, it becomes T | null.
⸻
Advanced Patterns
Distributive Conditional Types
Conditional types automatically distribute over unions:
type WrapInPromise<T> = T extends any ? Promise<T> : never;
type P = WrapInPromise<string | number>;
// => Promise<string> | Promise<number>
This pattern allows you to apply transformations to each member of a union.
Using infer to Extract Types
You can extract inner types from containers like Promise:
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type U1 = UnwrapPromise<Promise<string>>; // string
type U2 = UnwrapPromise<number>; // number
Combine this with recursion to create utilities like deep unwrapping.
⸻
Pitfalls and Best Practices • Detecting plain objects T extends object matches arrays and functions. If you want only plain objects, use:
T extends Record<string, unknown> ? … : …
• Recursion depth limits
Deeply nested types can slow down the TypeScript compiler. Scope your utilities carefully, and consider limiting depth. • Circular references TypeScript can’t fully represent recursive structures with circular references.
⸻
Applying These Utilities in Your Codebase 1. Centralize helpers Place utilities like DeepNullable or UnwrapPromise in a shared types/ module. 2. Document intent Use JSDoc to clarify edge cases, especially around exclusions like arrays or functions. 3. Limit scope Only apply deep utilities where needed. Favor shallow transforms for performance-sensitive areas.
⸻
Key Takeaway
Conditional types are more than just clever syntax—they’re a cornerstone of type-level programming in TypeScript. Mastering them unlocks powerful patterns for enforcing correctness at compile time, reducing bugs and eliminating runtime boilerplate across large-scale projects.