Mastering Conditional Types in TypeScript

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 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
  1. 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.