Conditional Types in TypeScript
Why Conditional Types Matter at Scale
As your TypeScript codebase grows, so does the need for abstractions that are expressive and type-safe. Conditional types give you type-level “if/else” logic, so you can build smarter, more maintainable utilities:
type IsString<T> = T extends string ? true : false;
type A = IsString<"foo">; // true
type B = IsString<42>; // false
This pattern underpins a wide range of type utilities (filtering object properties, transforming deeply nested structures) while preserving full type safety.
Building a DeepNullable Utility
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;
};
};
}
1. 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
2. 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
T extends objectchecks whether the value is an object (this includes arrays and functions).- The mapped type iterates over each key
KinT. - Recursive application applies
DeepNullableto each property. - Base case: if
Tis not an object, it becomesT | 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 lets you 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 objectmatches 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
- Centralize helpers: Place utilities like
DeepNullableorUnwrapPromisein a sharedtypes/module. - Document intent: Use JSDoc to clarify edge cases, especially around exclusions like arrays or functions.
- Limit scope: Only apply deep utilities where needed. Favor shallow transforms for performance-sensitive areas.
Prefer plain text? Read this as Markdown.