๐ŸŽ“ A1Learner
๐Ÿ”
0 of 37 lessons completed

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?