← Writing

Conditional Types in TypeScript

· 1y ago · 2 min read Evergreen
ts_types_image

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.