TypeScript Recursive Mapped Types
A recursive mapped type maps an object type and then calls itself on each member. DeepReadonly and DeepPartial are built this way, with a conditional type steering each member.
DeepReadonly maps every level
The conditional returns functions and arrays unchanged and maps any other object with readonly at every level. Plain values fall through to the last branch.
type DeepReadonly<T> = T extends (...args: any[]) => unknown ? T : T extends readonly unknown[] ? T : T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } : T;
type AppConfig = { server: { host: string; port: number }; debug: boolean };
const appConfig: DeepReadonly<AppConfig> = { server: { host: 'localhost', port: 8080 }, debug: true };
console.log(appConfig.server.host);
console.log(appConfig.server.port);
console.log(appConfig.debug);DeepPartial makes every level optional
The same skeleton with an optional mark builds DeepPartial. The patch supplies the theme two levels down and omits everything else.
type DeepPartial<T> = T extends (...args: any[]) => unknown ? T : T extends readonly unknown[] ? T : T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
type Settings = { ui: { theme: string; fontSize: number }; alerts: boolean };
const patch: DeepPartial<Settings> = { ui: { theme: 'dark' } };
console.log(patch.ui?.theme);
console.log(patch.alerts === undefined);Functions and arrays pass through
The conditional guards keep the array and the function member exactly as declared. The array still accepts push, and the function still runs.
type DeepReadonly<T> = T extends (...args: any[]) => unknown ? T : T extends readonly unknown[] ? T : T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } : T;
type Profile = { tags: string[]; save: () => string; address: { city: string } };
const profile: DeepReadonly<Profile> = { tags: ['new'], save: () => 'saved', address: { city: 'Oslo' } };
profile.tags.push('vip');
console.log(profile.tags.join(','));
console.log(profile.save());
console.log(profile.address.city);A write two levels down is refused
The readonly mark reaches the nested server object, not just the top level. Writing to its host member is refused.
type DeepReadonly<T> = T extends (...args: any[]) => unknown ? T : T extends readonly unknown[] ? T : T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } : T;
type AppConfig = { server: { host: string; port: number } };
const appConfig: DeepReadonly<AppConfig> = { server: { host: 'localhost', port: 8080 } };
appConfig.server.host = 'example.com';Example
type DeepReadonly<T> = T extends (...args: any[]) => unknown ? T : T extends readonly unknown[] ? T : T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } : T;
type DeepPartial<T> = T extends (...args: any[]) => unknown ? T : T extends readonly unknown[] ? T : T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
type Dashboard = { panels: { header: { title: string }; body: { rows: number } }; owner: string };
const dashboard: DeepReadonly<Dashboard> = { panels: { header: { title: 'Sales' }, body: { rows: 12 } }, owner: 'Ada' };
const patch: DeepPartial<Dashboard> = { panels: { body: { rows: 20 } } };
console.log(dashboard.panels.header.title);
console.log(dashboard.panels.body.rows);
console.log(patch.panels?.body?.rows);Key points
- The mapped type calls itself on T[K], so nested objects are mapped level by level.
- A conditional type in front of the map decides which values the map may enter.
- Function members pass through the conditional unmapped, so their signatures stay callable.
- Array members pass through unmapped as well, so the build never rewrites them into objects.
- DeepPartial makes every level optional, so a patch value can stop at any depth.
In this example, DeepReadonly freezes the dashboard at every depth while DeepPartial allows a patch to name only the rows member deep inside the shape. Both builds run the same recursive skeleton with different marks.
Try it Yourself
Edit the code, then press Run.
Running is simulated ยท TypeScript Playground
Exercise
Why do DeepReadonly and DeepPartial test for functions and arrays before mapping?