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.
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:
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 notThe 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:
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:
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:
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:
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:
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 ?:
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 Setvalues, 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
Readonlyplus 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
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.
Frequently Asked Questions
Why not just use the built-in Readonly type?
Does DeepReadonly affect 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.
More in this topic
TypeScript Compiler Explained
The TypeScript compiler (tsc) turns .ts files into .js files while checking types. Learn what it does, how to use it, and how it fits your workflow.
Set Up ESLint for TypeScript
Install and configure ESLint with typescript-eslint to catch bugs and enforce consistent code style in your TypeScript project.
Pick Utility Type in TypeScript
Pick creates a new type by selecting only the properties you name from an existing type. Use it to build focused types for specific views, API responses, or function parameters.