Build a DeepReadonly Type in TypeScript

Build a recursive type that makes every nested property readonly. Learn how to combine mapped types, conditional types, and recursion into one reusable utility.

7 min read

A TypeScript deep readonly type makes every nested property readonly, not just the top level. The built-in Readonly utility type only makes top-level properties readonly and leaves nested objects mutable: it prevents reassigning a user object's address property but does nothing to stop mutations to the street or city fields inside it. This article builds a recursive type that applies readonly to every nested level, combining mapped types, conditional types, and recursion into a single reusable utility.

The problem is easiest to see with a concrete example:

typescripttypescript
interface User {
  name: string;
  address: { street: string; city: string };
}
 
type ReadonlyUser = Readonly<User>;
 
const user: ReadonlyUser = {
  name: "Alice",
  address: { street: "123 Main", city: "Springfield" },
};
 
user.name = "Bob";
// Error: Cannot assign to 'name' because it is a read-only property.
 
user.address.city = "Shelbyville";
// No error -- address itself is readonly, but its properties are not

The address object is frozen at the reference level (you cannot replace user.address), but the properties inside it are still mutable. To make every nested property readonly, you need a recursive type.

Step 1: a mapped type for shallow readonly

Building DeepReadonly from scratch makes the built-in Readonly easier to understand, since the deep version is just this shallow version applied recursively. Start with the standard mapped type that makes properties readonly. This is what the built-in Readonly does:

typescripttypescript
type ShallowReadonly<T> = {
  readonly [K in keyof T]: T[K];
};

For each key in the input type, the output property is readonly with the same value type. This handles one level.

Step 2: add a conditional to detect objects

To go deeper, check whether each property value is an object. If it is, apply readonly recursively. If not, leave it alone:

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

The conditional T[K] extends object asks: is this property's value an object type? If yes, the type calls itself on that value. If no (it is a primitive like string, number, or boolean), the type stops and returns the value as-is.

Step 3: handle functions

Functions are objects in JavaScript, but you almost never want to recurse into a function type. Treat functions as a base case that stops the recursion:

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

The function check comes first. If the property is a function, return it unchanged. Otherwise, check if it is an object and recurse.

This is the complete DeepReadonly type. Here it is applied:

typescripttypescript
interface Config {
  server: {
    host: string;
    port: number;
    ssl: { enabled: boolean; certPath: string };
  };
  onReady: () => void;
}
 
type ReadonlyConfig = DeepReadonly<Config>;
 
declare const config: ReadonlyConfig;
 
config.server.ssl.enabled = false;
// Error: Cannot assign to 'enabled' because it is a read-only property.
 
config.onReady = () => {};
// Error: Cannot assign to 'onReady' because it is a read-only property.

Every property at every depth is now readonly. The function onReady is readonly at the reference level but its internal type is preserved.

How the recursion works

The type recurses on object properties. For a three-level-deep object, the flow is:

DeepReadonly recursion on a nested object

Each level checks: is this a function? Is this an object? If it is an object, recurse.

If not, stop. The recursion ends at primitives like string, number, and boolean.

For more on recursion in types, see Recursive Types in TypeScript. For more on mapped types, see Mapped Types in TypeScript.

Variations on the pattern

The same recursive mapped type pattern works for other deep transformations:

DeepPartial makes every nested property optional. Replace readonly with ?:

typescripttypescript
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends Function
    ? T[K]
    : T[K] extends object
      ? DeepPartial<T[K]>
      : T[K];
};

DeepRequired removes optionality. Add -? before the mapped type's colon.

DeepNonNullable removes null and undefined at every depth. Use NonNullable in the conditional.

All three follow the same structure: a mapped type, a conditional check for the base case, and a recursive call into nested objects.

When not to use DeepReadonly

DeepReadonly is useful for configuration objects, API contracts, and immutable state. It is less useful for:

  • Objects that contain Date, Map, or Set values, or other mutable built-ins. These are objects, so the type would recurse into their internal structure. Add explicit checks for these types if your objects contain them.
  • Objects with circular references. The type will hit TypeScript's recursion limit. Break circular references with intermediate type aliases.
  • Cases where only one or two levels need readonly protection. A non-recursive Readonly plus explicit nested readonly annotations is clearer for shallow nesting.

For more on when advanced types add complexity, see When Advanced Types Become Too Complex.

Rune AI

Rune AI

Key Insights

  • Mapped types iterate over keys and apply transformations to each property.
  • Conditional types branch based on whether a property is an object.
  • Recursive type references call the same type on nested values.
  • Always handle the base case: primitives, functions, and other non-object types.
  • This pattern generalizes to DeepPartial, DeepRequired, and other deep transformations.
RunePowered by Rune AI

Frequently Asked Questions

Why not just use the built-in Readonly type?

The built-in `Readonly<T>` only makes the top-level properties readonly. Nested objects inside remain mutable. `DeepReadonly` recurses into every nested object to make all properties readonly at every depth.

Does DeepReadonly affect arrays?

Yes, `readonly` on an array property makes it a `readonly` array type. You cannot call `.push()` or `.pop()` on it. If you want arrays to remain mutable, add a special case in the conditional type to skip arrays.

Conclusion

Building a DeepReadonly type combines three TypeScript features: mapped types for transforming properties, conditional types for branching logic, and recursion for depth. The result is a single type alias that applies readonly to every nested level of an object, something the built-in Readonly cannot do.