Right, so you’ve met Readonly<T>. It’s polite. It makes the top-level properties of an object readonly. Charming, but utterly useless against any object with a hint of depth. It’s like putting a single padlock on a Russian nesting doll – the outer shell is secure, but good luck protecting what’s inside.

We need to go deeper. We need a type that traverses every branch of an object’s tree and slaps a readonly modifier on every single property it finds. We need DeepReadonly<T>. And to build it, we need to understand recursive conditional types. Don’t panic; it’s less complicated than it sounds. It’s just a type that calls itself until it’s done.

Let’s start with the naive, non-recursive version. We know we want to make T readonly, but if T is an object, we need to do the same to all its properties.

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

This looks like it should work, right? We’re mapping over each property P. If the property’s type T[P] is an object, we recursively apply NaiveDeepReadonly to it. Otherwise, we just use T[P] as-is.

Here’s the first, and most crucial, lesson: TypeScript’s type system is structural, not nominal. An array is an object. A function is an object. Date is an object. Our naive type would happily try to make all of these things readonly, which is, to put it mildly, a catastrophe.

Try to assign to a Date method? Chaos. Try to make an array’s push method readonly? Nonsense. We’ve been too broad. We need to be more precise about what we mean by “object” here.

The Right Way: Narrowing with object and Primitives

We don’t want to recurse into just any object. We specifically want to recurse into plain objects (and arrays). We need to exclude all the built-in nasties like Function, Date, Promise, etc. The standard way to do this is to check if a type is a non-primitive object. We can do this by checking if it’s not a primitive.

type DeepReadonly<T> = T extends
  | Function
  | Date
  | RegExp
  | { readonly [Symbol.iterator]: any } // Catches Promises, Arrays, etc.
  ? T
  : T extends object
  ? {
      readonly [P in keyof T]: DeepReadonly<T[P]>;
    }
  : T;

Let’s break this down:

  1. The “Get Out of Jail Free” Clause: The first conditional checks if T is one of the types we never want to mess with (Function, Date, etc.) or if it has a Symbol.iterator (which catches Array, Map, Set, Promise, and any other iterable). If it matches any of these, we bail out and just return T unchanged. This is our circuit breaker.
  2. The “Is This an Object?” Check: If it passed the first check, we then see if it extends object. This is a narrower check that will catch plain objects and, crucially, arrays (which we already handled, but it’s a good safety net).
  3. The Recursion: If it’s a plain object, we map over its properties, applying DeepReadonly to each one. This is the magic. The type calls itself, drilling down until every property is either a primitive or one of our forbidden types from step 1.

Testing Our Creation

Let’s see it in action with a real, wonderfully messy object.

const messyObject = {
  prop: "string",
  nested: {
    nestProp: 42,
    nestArray: [1, 2, { insideArray: "deep" }],
  },
  fn: () => console.log("I'm a function, leave me alone"),
  birthdate: new Date(),
} as const; // 'as const' helps with literal types, but isn't needed for our test

type TestDeepReadonly = DeepReadonly<typeof messyObject>;
// Hover over TestDeepReadonly in your IDE to see the glorious result.

// The type equivalent would be:
/*
{
  readonly prop: "string";
  readonly nested: {
    readonly nestProp: 42;
    readonly nestArray: readonly [1, 2, {
        readonly insideArray: "deep";
    }];
  };
  readonly fn: () => void;
  readonly birthdate: Date;
}
*/

Perfect. Notice how:

  • prop and nestProp are readonly.
  • The nested object itself is readonly and so are all its members.
  • The array nestArray became a readonly tuple, and the object inside it is also deeply readonly.
  • Critically, the function fn and the Date object birthdate were left completely untouched. Victory.

The Pitfalls and the Power of infer

You might think, “Why not use T[] to check for arrays?” You could, and it would work. But what about Map? Or Set? The list of built-in types is long. The beauty of the approach above is that it’s a catch-all for any non-plain-object iterable. It’s future-proof and handles things you haven’t even thought of yet.

However, there’s an even more precise, albeit more complex, method using the infer keyword. What if you did want to handle certain built-in types, like Array or Map, in a specific way? You can use a recursive type with infer to peel them apart.

type SuperDeepReadonly<T> = T extends Function
  ? T
  : T extends Array<infer U>
  ? ReadonlyArray<SuperDeepReadonly<U>>
  : T extends Map<infer K, infer V>
  ? ReadonlyMap<SuperDeepReadonly<K>, SuperDeepReadonly<V>>
  : T extends Set<infer U>
  ? ReadonlySet<SuperDeepReadonly<U>>
  : T extends Promise<infer U>
  ? Promise<SuperDeepReadonly<U>> // Promises are meant to be mutable handles, so this is often correct
  : T extends object
  ? {
      readonly [P in keyof T]: SuperDeepReadonly<T[P]>;
    }
  : T;

This is the full, no-holds-barred approach. It’s more computationally expensive for the type checker, but it gives you surgical precision. The infer keyword lets us capture the inner types (U, K, V) so we can recursively apply our readonly transformation to the contents of the Array, Map, etc., while using TypeScript’s own built-in readonly versions of those structures (ReadonlyArray, ReadonlyMap).

So, which should you use? For 99% of applications, the first DeepReadonly type is perfectly sufficient and far more performant. It protects your data from accidental mutation without getting bogged down in the specifics of every collection type. Save the SuperDeepReadonly for when you absolutely need that level of control, and be prepared for your IDE to occasionally hitch as it computes these deeply nested types. The compiler is powerful, but it’s not a magician. It gets tired, just like us.