文章

递归类型实现深度解析

递归类型实现深度解析

一句话概括

递归类型(Recursive Types)让 TypeScript 的类型系统具备了”深度遍历”的能力——DeepReadonly<T>DeepPartial<T>DeepRequired<T> 通过递归地将映射类型应用到每个嵌套层级,将单层的属性变换推广到任意深度的对象结构。

一、背景与意义

1.1 从浅层到深层

内置的 Readonly<T>Partial<T>Required<T> 只作用于对象的第一层属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
interface Config {
  name: string;
  database: {
    host: string;
    port: number;
    credentials: {
      user: string;
      password: string;
    };
  };
  features: {
    darkMode: boolean;
    notifications: boolean;
  };
}

// Readonly 只作用在第一层
type ReadonlyConfig = Readonly<Config>;
// { 
//   readonly name: string; 
//   readonly database: { host: string; port: number; credentials: { ... } }; 
//   readonly features: { darkMode: boolean; notifications: boolean };
// }
// ❌ database 内部的属性仍然是可变的!

在实战中,我们经常需要深度不可变的对象——比如 Redux store、状态快照、配置对象。这时就需要 DeepReadonly 这样的递归类型。

1.2 递归类型的应用场景

场景工具类型说明
不可变状态DeepReadonly<T>深度只读,防止状态被意外修改
配置合并DeepPartial<T>深度可选,只覆盖需要修改的配置项
表单验证DeepRequired<T>深度必选,确保所有字段都被填写
类型抹平Flatten<T>将嵌套类型展平为单层
路径提取Path<T>提取对象所有可能的属性路径

1.3 递归类型的挑战

虽然内置工具类型没有提供递归版本,但 TypeScript 类型系统本身是支持递归的。手写递归类型面临三个挑战:

  1. 递归终止:什么时候停止递归?如何处理数组、Map、Set 等特殊类型?
  2. 深度限制:TypeScript 对类型递归有深度限制(50 层)
  3. 性能:深层递归类型在编译时可能造成较大的计算开销

二、概念与定义

2.1 递归类型的基本模式

递归类型(Recursive Type)是类型定义中引用自身的类型:

1
2
3
4
5
6
7
8
// 递归类型的三个要素:
// 1. 泛型参数 T
// 2. 条件判断:何时递归、何时终止
// 3. 在 true 分支中递归引用自身

type RecursiveExample<T> = T extends object 
  ? { [P in keyof T]: RecursiveExample<T[P]> }  // 递归
  : T;                                            // 终止

2.2 三种模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 模式 1:深度变换——递归地改变属性修饰符
type DeepProxy<T> = {
  [P in keyof T]: T[P] extends object ? DeepProxy<T[P]> : T[P];
};

// 模式 2:深度提取——递归地遍历并提取信息
type DeepKeys<T> = T extends object
  ? { [K in keyof T]: K | DeepKeys<T[K]> }[keyof T]
  : never;

// 模式 3:深度转换——递归地改变类型结构
type DeepStringify<T> = T extends object
  ? { [K in keyof T]: DeepStringify<T[K]> }
  : string;

2.3 结构类型 vs 元组/数组

递归处理时,对象、数组、元组、Map、Set 等都需要不同的处理方式:

1
2
3
4
5
6
7
8
// 对象的递归
{ [P in keyof T]: DeepX<T[P]> }

// 数组的递归(保持数组结构)
T extends Array<infer U> ? DeepX<U>[] : ...

// 元组的递归(映射类型保持元组)
{ [P in keyof T]: DeepX<T[P]> }  // 对元组也有效!

三、最小示例:DeepReadonly / DeepPartial / DeepRequired 手写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
// ========== 1. DeepReadonly<T> ==========
// 深度只读:对象所有层级的所有属性都变为 readonly

type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends Record<string, unknown>
    ? DeepReadonly<T[P]>
    : T[P] extends Array<infer U>
      ? ReadonlyArray<DeepReadonly<U>>
      : T[P];
};

// ========== 2. DeepPartial<T> ==========
// 深度可选:对象所有层级的所有属性都变为可选

type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends Record<string, unknown>
    ? DeepPartial<T[P]>
    : T[P] extends Array<infer U>
      ? Array<DeepPartial<U>>
      : T[P];
};

// ========== 3. DeepRequired<T> ==========
// 深度必选:对象所有层级的可选属性都变为必选

type DeepRequired<T> = {
  [P in keyof T]-?: T[P] extends Record<string, unknown>
    ? DeepRequired<T[P]>
    : T[P] extends Array<infer U>
      ? Array<DeepRequired<U>>
      : T[P];
};

// ========== 验证 ==========
interface NestedConfig {
  name: string;
  settings?: {
    theme: 'light' | 'dark';
    colors?: {
      primary: string;
      secondary?: string;
    };
    plugins?: string[];
  };
  data: {
    users: Array<{
      id: number;
      profile?: {
        nickname: string;
        avatar?: string;
      };
    }>;
  };
}

// DeepReadonly 验证
type ReadonlyConfig = DeepReadonly<NestedConfig>;
// 所有层级全部 readonly
const cfg: ReadonlyConfig = {
  name: 'app',
  settings: {
    theme: 'light',
    colors: { primary: '#000' },
    plugins: ['plugin1'],
  },
  data: {
    users: [{ id: 1, profile: { nickname: 'Alice' } }],
  },
};
// cfg.name = 'new';          // ❌ Error: readonly
// cfg.settings!.theme = 'dark'; // ❌ Error: readonly
// cfg.settings!.colors!.primary = '#fff'; // ❌ Error: readonly 3 层!
// cfg.data.users[0].id = 2;  // ❌ Error: readonly(数组元素也是 readonly)

// DeepPartial 验证
type PartialConfig = DeepPartial<NestedConfig>;
const pc: PartialConfig = {
  name: 'partial-app',
  settings: {
    // 只需要提供 settings 的部分字段
    theme: 'dark',
    // colors 可选,可以不传
  },
  // data 可选!
};

// DeepRequired 验证
type RequiredConfig = DeepRequired<PartialConfig>;
// 所有层级的可选属性都变为必选
const rc: RequiredConfig = {
  name: 'required',
  settings: {
    theme: 'light',
    colors: { primary: 'red' },        // secondary 也必选了!
    plugins: [],
  },
  data: { users: [{ id: 1, profile: { nickname: 'Bob', avatar: '' } }] },
};

// ========== 测试函数 ==========
function testDeepTypes() {
  // DeepReadonly 运行时验证
  const fn = (config: ReadonlyConfig) => {
    // 编译时 readonly,不会出错
    console.log(config.name); 
    console.log(config.data.users[0].profile?.nickname);
  };
  fn(cfg);
  
  console.log('All type checks passed!');
}

四、核心知识点拆解

4.1 递归终止条件:什么算是”对象”?

递归的核心问题是:什么时候停止?错误地判断”对象”类型会导致无限递归:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ❌ 错误的递归:所有类型都"是" object
type BadDeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object ? BadDeepReadonly<T[P]> : T[P];
};
// 数组也是 object!string 的键也是 object(因为包装对象)
// 这会导致对 string、number 等基本类型也执行 keyof,然后无限递归

// ✅ 正确的递归:只对"纯对象"递归
type DeepReadonlyCorrect<T> = {
  readonly [P in keyof T]: 
    T[P] extends Record<string, unknown> ? DeepReadonlyCorrect<T[P]>
    : T[P] extends Array<infer U> ? ReadonlyArray<DeepReadonlyCorrect<U>>
    : T[P];
};

Record<string, unknown> 过滤了哪些类型?

  • { name: string } → 匹配(纯对象)
  • { foo: number, bar: Date } → 匹配
  • string → 不匹配(不是 Record)
  • number → 不匹配
  • string[] → 不匹配(被数组条件先匹配)
  • Map<string, any> → 不匹配(没有字符串索引签名)

4.2 数组与元组的特殊处理

数组和元组在 TypeScript 中的行为略有不同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type DeepReadonlyArr<T> = {
  readonly [P in keyof T]: T[P] extends Array<infer U>
    ? ReadonlyArray<DeepReadonlyArr<U>>
    : T[P] extends Record<string, unknown>
      ? DeepReadonlyArr<T[P]>
      : T[P];
};

// 数组测试
type ArrayTest = DeepReadonlyArr<{
  users: Array<{ name: string; tags: string[] }>;
}>;
// users: ReadonlyArray<{ readonly name: string; readonly tags: ReadonlyArray<string> }>

// 元组测试(TS 映射类型对元组保持结构)
type TupleTest = DeepReadonlyArr<[string, number, { x: boolean }]>;
// [string, number, { readonly x: boolean }] — 注意 string 和 number 没有被递归!

对于元组,TypeScript 的映射类型会保留元组的长度和顺序,将每个元素类型映射为新的元素类型。所以 DeepReadonlyArr<[string, number, boolean]> 的结果仍然是 [string, number, boolean]

4.3 函数类型的处理

函数(包括类)不应该被递归,因为函数体内部是运行时行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type DeepReadonlyFnTest<T> = {
  readonly [P in keyof T]: T[P] extends (...args: any[]) => any
    ? T[P]  // 函数类型保持原样
    : T[P] extends Array<infer U>
      ? ReadonlyArray<DeepReadonlyFnTest<U>>
      : T[P] extends Record<string, unknown>
        ? DeepReadonlyFnTest<T[P]>
        : T[P];
};

interface WithFn {
  name: string;
  callback: () => void;
  handler: (id: number) => Promise<string>;
}

type Test = DeepReadonlyFnTest<WithFn>;
// { readonly name: string; readonly callback: () => void; readonly handler: (id: number) => Promise<string>; }

4.4 Date、Map、Set 等内置对象

这些内置对象本身就是”对象”,但它们的内部结构不应该被递归:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type PrimitiveObject = Date | RegExp | Map<any, any> | Set<any> | WeakMap<any, any>;

type DeepReadonlyWithPrimitives<T> = {
  readonly [P in keyof T]: 
    T[P] extends PrimitiveObject ? T[P]       // 内置对象保持原样
    : T[P] extends (...args: any[]) => any ? T[P]  // 函数保持原样
    : T[P] extends Array<infer U> ? ReadonlyArray<DeepReadonlyWithPrimitives<U>>
    : T[P] extends Record<string, unknown> ? DeepReadonlyWithPrimitives<T[P]>
    : T[P];
};

// 简化版(生产可用)
type DeepReadonlySimple<T> = T extends Primitive | PrimitiveObject | Function
  ? T
  : T extends Array<infer U>
    ? ReadonlyArray<DeepReadonlySimple<U>>
    : { readonly [P in keyof T]: DeepReadonlySimple<T[P]> };

type Primitive = string | number | boolean | null | undefined | symbol | bigint;

4.5 递归深度限制

TypeScript 的类型递归有固有深度限制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type DeepRecursive<T> = T extends object
  ? { [K in keyof T]: DeepRecursive<T[K]> }
  : T;

// 对于 50 层嵌套的对象:
type Obj50 = { a: { a: { ... 50  ... { x: number } } } };
type Res50 = DeepRecursive<Obj50>; // 可能触发深度限制

// 解决方案:分段递归
type DeepReadonlyLevel1<T> = { readonly [P in keyof T]: T[P] };
type DeepReadonlyLevel2<T> = { readonly [P in keyof T]: DeepReadonlyLevel1<T[P]> };
// ... 手动展开 N 层

// 或使用条件递归(建议最多 10 层,再多就应重新设计类型)
type DeepReadonlyLimited<T, Depth extends number = 5> = Depth extends 0
  ? { readonly [P in keyof T]: T[P] } // 达到深度限制,停止递归
  : {
      readonly [P in keyof T]: T[P] extends Record<string, unknown>
        ? DeepReadonlyLimited<T[P], Subtract<Depth, 1>>
        : T[P];
    };

// 注意:TypeScript 无法在类型层面做算术运算(Subtract 不可实现)
// 这里只是展示概念

五、实战案例:深度配置合并系统

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
// ========== 场景:应用配置管理 ==========

// 基础配置接口(所有项都有默认值)
interface AppConfig {
  server: {
    host: string;
    port: number;
    ssl: {
      enabled: boolean;
      cert: string;
      key: string;
    };
  };
  database: {
    type: 'postgres' | 'mysql' | 'sqlite';
    host: string;
    port: number;
    username: string;
    password: string;
    poolSize: number;
    pool: {
      min: number;
      max: number;
      idleTimeout: number;
    };
  };
  logging: {
    level: 'debug' | 'info' | 'warn' | 'error';
    format: 'json' | 'text';
    output: 'stdout' | 'file' | 'both';
    file?: {
      path: string;
      maxSize: number;
      maxFiles: number;
    };
  };
  features: {
    cache: {
      enabled: boolean;
      ttl: number;
      provider: 'memory' | 'redis';
      redis?: {
        host: string;
        port: number;
        password?: string;
      };
    };
    analytics: {
      enabled: boolean;
      provider: 'google' | 'mixpanel' | 'none';
      trackingId?: string;
    };
  };
}

// ---------- 1. 用户自定义配置:DeepPartial ----------
// 用户只需要覆盖需要的字段

const userConfig: DeepPartial<AppConfig> = {
  server: {
    port: 8080,  // 只覆盖 port
  },
  database: {
    type: 'postgres',
    host: 'localhost',
    port: 5432,
    username: 'app',
    password: 'secret',
    poolSize: 10,
    pool: { min: 2, max: 10, idleTimeout: 30000 },
  },
  features: {
    cache: {
      enabled: true,
      ttl: 3600,
      provider: 'redis',
      redis: {
        host: 'redis.local',
        port: 6379,
      },
    },
  },
};

// ---------- 2. 默认配置合并 ----------

// 深度合并两个类型
type DeepMerge<T, U> = {
  [K in keyof (T & U)]: K extends keyof U
    ? U[K] extends Record<string, unknown>
      ? K extends keyof T
        ? DeepMerge<T[K], U[K]>
        : U[K]
      : U[K]
    : K extends keyof T
      ? T[K]
      : never;
};

// 合并后的配置类型
type MergedConfig = DeepMerge<AppConfig, typeof userConfig>;
// 结果:所有未在 userConfig 中提供的字段保持默认值

// ---------- 3. 发布后的运行时配置:DeepReadonly ----------
// 配置一旦生效,不应该被修改

const defaultConfig: DeepReadonly<Required<AppConfig>> = {
  server: {
    host: '0.0.0.0',
    port: 3000,
    ssl: {
      enabled: false,
      cert: '',
      key: '',
    },
  },
  database: {
    type: 'sqlite',
    host: 'localhost',
    port: 3306,
    username: 'app',
    password: '',
    poolSize: 5,
    pool: { min: 1, max: 5, idleTimeout: 60000 },
  },
  logging: {
    level: 'info',
    format: 'json',
    output: 'stdout',
  },
  features: {
    cache: {
      enabled: true,
      ttl: 300,
      provider: 'memory',
    },
    analytics: {
      enabled: false,
      provider: 'none',
    },
  },
};

/**
 * 生成运行时不可变配置
 * 合并默认配置和用户配置,然后深度冻结
 */
function createConfig(
  defaults: DeepReadonly<AppConfig>,
  overrides: DeepPartial<AppConfig>,
): DeepReadonly<AppConfig> {
  const merged = deepMerge(defaults as any, overrides as any);
  return deepFreeze(merged) as DeepReadonly<AppConfig>;
}

// 深度合并实现(运行时)
function deepMerge(target: any, source: any): any {
  const output = { ...target };
  if (isObject(target) && isObject(source)) {
    Object.keys(source).forEach(key => {
      if (isObject(source[key]) && key in target) {
        output[key] = deepMerge(target[key], source[key]);
      } else {
        output[key] = source[key];
      }
    });
  }
  return output;
}

function isObject(item: any): item is Record<string, unknown> {
  return item && typeof item === 'object' && !Array.isArray(item);
}

// 深度冻结实现(运行时)
function deepFreeze<T>(obj: T): DeepReadonly<T> {
  if (obj && typeof obj === 'object' && !Object.isFrozen(obj)) {
    Object.freeze(obj);
    Object.getOwnPropertyNames(obj).forEach(prop => {
      const value = (obj as any)[prop];
      if (value && typeof value === 'object' && !Object.isFrozen(value)) {
        deepFreeze(value);
      }
    });
  }
  return obj as DeepReadonly<T>;
}

// ---------- 4. 使用 ----------
const runtimeConfig = createConfig(defaultConfig, userConfig);
// runtimeConfig 在编译时和运行时都是深度不可变的

// runtimeConfig.server.port = 9090; // ❌ TS Error: readonly
// runtimeConfig.database.pool.min = 5; // ❌ TS Error: readonly(深层也被保护)
console.log(runtimeConfig.server.port); // 8080(用户配置覆盖了默认值)

六、底层原理

6.1 TypeScript 如何处理递归类型

TypeScript 编译器在处理递归类型时,会进行延迟求值(Deferred Evaluation)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends Record<string, unknown>
    ? DeepReadonly<T[P]>
    : T[P];
};

// 当编译器遇到 DeepReadonly<Config> 时:
// 1. 展开 keyof Config = 'name' | 'settings' | 'data'
// 2. 处理 'name' -> string,不是 Record → 终止
// 3. 处理 'settings' -> { theme: ... } 是 Record → DeepReadonly<Settings>
//    3.1 递归展开 keyof Settings
//    3.2 处理 'theme' -> 'light' | 'dark',不是 Record → 终止
//    3.3 处理 'colors' -> { primary, secondary } 是 Record → DeepReadonly<Colors>
//    3.4 处理 'plugins' -> string[] 是 Array → ReadonlyArray<string>
// 4. 处理 'data' -> { users: [...] } → 继续递归...

关键洞察:递归类型在编译时被完全展开(Fully Resolved)。这意味着编译器会实际计算所有层级的类型,直到递归终止。

6.2 条件类型在递归中的作用

条件类型在递归类型中扮演着模式匹配器的角色:

1
2
3
4
5
6
7
8
9
type DeepReadonly<T> = {
  readonly [P in keyof T]: 
    // 条件 1: 如果是纯对象 → 递归
    T[P] extends Record<string, unknown> ? DeepReadonly<T[P]>
    // 条件 2: 如果是数组 → 只读数组 + 递归元素
    : T[P] extends Array<infer U> ? ReadonlyArray<DeepReadonly<U>>
    // 条件 3: 基础类型 → 直接返回
    : T[P];
};

这三个条件的顺序很重要——Array 也满足 extends object 的条件,所以数组条件必须在纯对象条件之前检查,否则数组会被当作纯对象递归处理。

6.3 递归类型的性能影响

深层递归类型会在编译时产生显著的性能开销。 递归类型在编译时会被完全展开,因此嵌套深度直接影响编译开销。TypeScript 内置了约 50 层的递归上限(超过报 TS2589),实际日常业务 3-5 层完全无感。

性能优化要点:

  1. 先用条件类型判断 T extends object 再递归,避免不必要的展开
  2. 高频递归类型有 TS 内置缓存,重复使用不额外消耗
  3. 深层递归 + 大量联合展开组合时可能 n^n 爆炸——如果在项目中需要超过 50 层的类型递归,说明类型设计可能过度抽象

七、高频面试题解析

面试题 1:实现 DeepMutable<T>,将 DeepReadonly<T> 变为深度可变的版本。

问题分析:与 DeepReadonly 相反,移除所有 readonly 修饰符。需要处理 ReadonlyArrayArray 的转换。

深度解答

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
type DeepMutable<T> = {
  -readonly [P in keyof T]: 
    T[P] extends ReadonlyArray<infer U>
      ? Array<DeepMutable<U>>
      : T[P] extends Record<string, unknown>
        ? DeepMutable<T[P]>
        : T[P];
};

// 验证
interface ReadonlyDeep {
  readonly name: string;
  readonly items: ReadonlyArray<Readonly<{ id: number; readonly label: string }>>;
}

type MutableDeep = DeepMutable<ReadonlyDeep>;
// { 
//   name: string; 
//   items: Array<{ id: number; label: string }>;
// }

// 测试
const m: MutableDeep = { name: 'test', items: [{ id: 1, label: 'a' }] };
m.name = 'new'; // OK(不再是 readonly)
m.items[0].label = 'b'; // OK(Array 的元素也不再是 readonly)

关键点

  • -readonly 移除 readonly 修饰符
  • ReadonlyArray<infer U> 匹配 readonly 数组
  • 需要递归处理数组元素类型

面试题 2:实现 DeepNullable<T>,将所有属性都变为可为 null 的类型(包括嵌套属性)。

深度解答

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
type DeepNullable<T> = {
  [P in keyof T]: T[P] extends Record<string, unknown>
    ? DeepNullable<T[P]> | null
    : T[P] extends Array<infer U>
      ? Array<DeepNullable<U>> | null
      : T[P] | null;
};

// 验证
interface UserProfile {
  name: string;
  address: {
    city: string;
    zip: number;
  };
  tags: string[];
}

type NullableProfile = DeepNullable<UserProfile>;
// {
//   name: string | null;
//   address: { city: string | null; zip: number | null; } | null;
//   tags: Array<string | null> | null;
// }

// 使用
const n: NullableProfile = {
  name: null,           // OK
  address: null,        // OK
  tags: ['a', null],    // OK(数组元素也可 null)
};

// 进阶:类型安全的深度 null 清理
function compactNull<T>(obj: DeepNullable<T>, depth?: number): T {
  // 运行时实现:递归移除所有 null 值
  return obj as T;
}

面试题 3:DeepReadonlyDateMap 等内置对象的影响如何?如何正确处理它们?

深度解答

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 问题:对 Date 使用 DeepReadonly
const date: DeepReadonly<Date> = new Date();
// date.getFullYear() — 但 getFullYear 是 Date 原型上的方法,保持可用

// 但如果做出错误的递归:
type BadDeepReadonly<T> = {
  readonly [P in keyof T]: BadDeepReadonly<T[P]>;
};

const bad: BadDeepReadonly<Date> = new Date() as any;
// bad.getFullYear ❌ getFullYear 会被当作 BadDeepReadonly 处理
// 因为 BadDeepReadonly 无条件递归,连 prototype 方法都递归了

// 正确的处理方式:
type SafeDeepReadonly<T> = 
  T extends Primitive ? T
  : T extends Date ? T        // Date 保持原样
  : T extends RegExp ? T      // RegExp 保持原样
  : T extends Map<infer K, infer V> ? ReadonlyMap<SafeDeepReadonly<K>, SafeDeepReadonly<V>>
  : T extends Set<infer U> ? ReadonlySet<SafeDeepReadonly<U>>
  : T extends Array<infer U> ? ReadonlyArray<SafeDeepReadonly<U>>
  : T extends (...args: any[]) => any ? T  // 函数保持原样
  : { readonly [P in keyof T]: SafeDeepReadonly<T[P]> };

type Primitive = string | number | boolean | null | undefined | symbol | bigint;

// 验证
type SafeTest = SafeDeepReadonly<{
  name: string;
  createdAt: Date;
  metadata: Map<string, { value: number }>;
  tags: Set<string>;
}>;
// {
//   readonly name: string;
//   readonly createdAt: Date;  // Date 保持原样
//   readonly metadata: ReadonlyMap<string, { readonly value: number }>;
//   readonly tags: ReadonlySet<string>;
// }

八、总结与扩展

核心要点

  1. 递归终止是递归类型最大的陷阱:正确判断什么算是”对象”、什么算是”基本类型”
  2. 数组和元组需要特殊处理ReadonlyArray<DeepReadonly<U>> 保持数组结构
  3. 内置对象(Date、Map 等)不能递归:它们的方法不应该被映射类型破坏
  4. 深度限制是硬约束:TypeScript 50 层递归限制通常够用,但复杂类型可能触发

扩展:更多实用递归类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 深度非空——移除所有 null | undefined
type DeepNonNullable<T> = {
  [P in keyof T]-?: T[P] extends Record<string, unknown>
    ? DeepNonNullable<NonNullable<T[P]>>
    : T[P] extends Array<infer U>
      ? Array<DeepNonNullable<NonNullable<U>>>
      : NonNullable<T[P]>;
};

// 深度字符串化——所有值转 string
type DeepStringify<T> = {
  [P in keyof T]: T[P] extends Record<string, unknown>
    ? DeepStringify<T[P]>
    : T[P] extends Array<infer U>
      ? Array<DeepStringify<U>>
      : string;
};

// 深度克隆类型——保持结构但移除所有只读/可选修饰符
type DeepClone<T> = DeepNonNullable<DeepMutable<DeepRequired<T>>>;

递归类型是 TypeScript 类型体操中最强大的工具之一。掌握了它,就能在类型层面安全地处理任意深度的嵌套数据结构。

本文由作者按照 CC BY 4.0 进行授权