Skip to Content
Course content

36: Recursive Conditional Types

Click on the "Edit" button in the top corner of the screen to edit your slide content.

You've likely already used the built-in Readonly<T> utility. It's great for a quick win when you want to ensure a prop or a config object doesn't get mutated. But as soon as your data structures get more than one level deep, you'll notice that Readonly is actually quite lazy—it's shallow. If you have a nested object, TypeScript will stop its protection at the first level, leaving your deep state wide open for mutations.

The illusion of immutability

Imagine we're building a complex state tree for a game engine. We have a GameState that contains a Player, and that player has a Position object. If we wrap the whole thing in Readonly, we feel safe, but we aren't.

type GameState = {
  score: number;
  player: {
    name: string;
    position: { x: number; y: number };
  };
};

const state: Readonly<GameState> = {
  score: 0,
  player: {
    name: "Hero",
    position: { x: 10, y: 20 }
  }
};

// This will error, as expected.
state.score = 10; 

// This also errors.
state.player.name = "Villain"; 

// Wait... this actually works. 
state.player.position.x = 50; 

This is the "naive" way of handling immutability. We're applying a transformation to the top-level keys, but we're ignoring the types of the values those keys hold. Because Readonly doesn't recurse, state.player.position is still just a regular mutable object. In a large codebase, this is how bugs sneak in—you think you're working with an immutable snapshot, but a nested property is being changed by reference elsewhere.

Teaching the type to dig deeper

To fix this, we need the type to ask a question: "Is the value I'm currently looking at another object? If so, I should apply this same logic to that object too." This is where recursive conditional types come in. We aren't just mapping keys; we're conditionally triggering the type definition itself.

type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object 
    ? DeepReadonly<T[P]> 
    : T[P];
};

Look at that ternary operator inside the mapped type. I'm checking if T[P] (the value of the current property) extends object. If it does, the type "calls itself" by returning DeepReadonly<T[P]>. If it's a primitive—like a string or a number—it just returns the value as is. This creates a recursive loop that drills down until it hits the bottom of your data structure.

Where this approach breaks

Now, if we were reviewing this in a PR, I'd point out one major caveat: object in TypeScript is very broad. It includes arrays, dates, and regexes. If your state contains a Date object, DeepReadonly will try to map over the internal properties of the Date class, which is almost certainly not what you want. You'll end up with a weird, stripped-down version of a Date.

To handle this properly in a production environment, you'd want to refine your conditional. You might check if the type is specifically an array or if it's a known "leaf" object like a Date. For example, you could add a check like T[P] extends Date ? T[P] : ... to stop the recursion early. The trade-off is complexity; the more "special cases" you add to your recursive type, the harder it is for your teammates to reason about what the final output will actually be. But for most nested data trees, this recursive pattern is the only way to get true type-level safety.




📋 Practical Task

Building a DeepPartial for API Request Payloads

In many REST APIs, a PATCH request allows you to send only the fields you want to update. A standard Partial<T> makes all top-level properties optional, but it doesn't handle nested objects. If you send a partial update to a nested settings object, you don't want to be forced to provide every single setting just to change one flag.

Your Task: Create a recursive conditional type called DeepPartial<T>. This type should make every property of T optional, and if a property is an object, it should recursively make its properties optional as well.

Test your implementation against the following interface. Ensure that update can be assigned a value where only user.profile.bio is provided, without requiring user.profile.avatar or user.id.

interface UserProfile {
  id: string;
  profile: {
    avatar: string;
    bio: string;
    socials: {
      twitter: string;
      github: string;
    };
  };
}

// Your type implementation here:
type DeepPartial<T> = {
  // TODO: Implement recursive partial logic
};

const update: DeepPartial<UserProfile> = {
  profile: {
    bio: "Software Engineer"
    // avatar and socials should be optional here
  }
};
Rating
0 0

There are no comments for now.

to be the first to leave a comment.