← Writing

Conditional Types in TypeScript

· 1y ago · 2 min read Evergreen
Title card reading Mastering Conditional Types in TypeScript next to the TypeScript logo

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

  1. T extends object checks whether the value is an object (this includes arrays and functions).
  2. The 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 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 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.