文章

递归类型实现深度解析

递归类型让 TS 类型系统能钻进对象的深层结构逐层施加映射变换,就像运行时的深拷贝但发生在类型层面。 核心是 DeepReadonly 这类递归映射类型,配合条件类型判断是否到达原始类型、数组、函数后停止递归,面试常考递归终止条件。

递归类型实现深度解析

一句话概括

Readonly<T> 只冻结第一层,DeepReadonly<T> 冻结到底——递归类型让 TypeScript 的类型系统能”钻”进 { a: { b: { c: number } } } 的深层结构,对每一层都施加映射变换,就像运行时的深度克隆,只不过是在类型层面。

核心知识点

1. DeepReadonly:递归版 Readonly

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object
    ? T[P] extends Function  // 函数不进入递归,保持原样
      ? T[P]
      : DeepReadonly<T[P]>   // 是对象 → 递归深入
    : T[P];                   // 是基本类型 → 停止
};

// 测试
interface Config {
  db: { host: string; port: number };
  cache: { ttl: number; exclude: string[] };
}
type Frozen = DeepReadonly<Config>;
// Frozen = {
//   readonly db: { readonly host: string; readonly port: number };
//   readonly cache: { readonly ttl: number; readonly exclude: readonly string[] };
// }
// ✅ 所有嵌套层级都只读了

递归终止条件:遇到基本类型(string | number | boolean | null | undefined | ...)或函数时停止。TypeScript 类型递归有深度限制(约 50 层),实际场景基本够用。

2. DeepPartial / DeepRequired:递归版可选/必选

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// DeepPartial:适合"深度合并配置"场景
type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object
    ? T[P] extends Function ? T[P] : DeepPartial<T[P]>
    : T[P];
};

// DeepRequired:适合"表单全部校验"场景
type DeepRequired<T> = {
  [P in keyof T]-?: T[P] extends object ? DeepRequired<T[P]> : T[P];
};

// 实战:用户只需传要覆盖的配置项
function mergeConfig(defaults: AppConfig, overrides: DeepPartial<AppConfig>): AppConfig {
  return { ...defaults, ...overrides }; // 简化示意
}
mergeConfig(defaultConfig, { db: { host: 'new-host' } }); // ✅ 只需要提供要改的字段

3. 递归条件的边界处理:数组、Map、Date

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 数组需要特殊对待,否则 [P in keyof T] 会遍历索引(0,1,2...)和 length 等
type DeepReadonlyFixed<T> = T extends any[] 
  ? readonly T[number][]  // 数组:只需把元素变只读
  : T extends Map<infer K, infer V> ? ReadonlyMap<K, V> 
  : T extends Set<infer S> ? ReadonlySet<S> 
  : T extends Date ? Date   // Date 是对象但要原样保留
  : { readonly [P in keyof T]: DeepReadonlyFixed<T[P]> };

// 验证
type Test = DeepReadonlyFixed<{
  items: Array<{ id: number; name: string }>;
  cache: Map<string, { data: number }>;
}>;
// items → readonly { readonly id: number; readonly name: string }[]
// cache → ReadonlyMap<string, { readonly data: number }>

4. Flatten:递归展平深层嵌套数组

1
2
3
4
5
6
7
8
9
type Flatten<T> = T extends (infer U)[]  // 如果是数组
  ? U extends any[] 
    ? Flatten<U>     // 元素还是数组 → 继续展平
    : U               // 元素不是数组 → 终止
  : never;

type F1 = Flatten<number>;           // never ← 不是数组
type F2 = Flatten<number[]>;         // number
type F3 = Flatten<number[][][]>;    // number ← 递归 3 层

5. Paths:提取对象所有属性路径(模板字面量 + 递归)

1
2
3
4
5
6
7
// 生成所有可能路径,如 'db.host' | 'db.port' | 'cache.ttl'...
type Paths<T> = T extends object
  ? { [K in keyof T & string]: K | `${K}.${Paths<T[K]>}` }[keyof T & string]
  : never;

type ConfigPaths = Paths<{ db: { host: string; port: number }; cache: { ttl: number } }>;
// 'db' | 'db.host' | 'db.port' | 'cache' | 'cache.ttl'

React Hook Form 的 register('field.nested.value') 类型安全,就是用类似 Paths 的思路实现的。

其实你每天都在用

  1. Object.freeze 的类型缺失——JS 的 Object.freeze 只冻结第一层,但 TypeScript 的 Readonly<T> 也只标注第一层只读。DeepReadonly<T> 填补了这个差距,让你能在类型层面保证”真正冻结”。
  2. React useState 的组合状态——setConfig(prev => ({ ...prev, db: { ...prev.db, host: newHost } })),如果你在 TypeScript 中用 Readonly<Config> 包裹,prev.db.host = ... 就会报错(因为 db 不是 readonly)。DeepReadonly 让深层不可变也能被类型系统检查。
  3. GraphQL 查询结果类型——Apollo 的 useQuery 返回的 data 类型实际上就是沿着查询 AST 递归构建的。你写了三层嵌套 { user { posts { title } } },返回就是 { user: { posts: { title: string }[] } }——类型系统递归地从 schema 映射到查询结果。
  4. immer 的 produce 类型——produce(state, draft => { draft.nested.value = 5 }) 的 draft 类型是深度可写的镜像类型,本质是 DeepWritable<T>(递归移除 readonly)。immer 本身依赖这个类型推导。
  5. Zod / io-ts 的 schema 类型提取——z.object({ user: z.object({ name: z.string() }) }) 推断出类型 { user: { name: string } },背后的 z.infer<typeof schema> 就是递归地遍历 schema 对象并映射到类型。和我们的 DeepReadonly 模式一模一样,区别在于映射方向(schema → 类型 vs 类型 → 类型变体)。

常见误解(FAQ)

  • ❌ 误区:「递归类型会导致 TypeScript 编译变慢」 真相:少量递归(10 层以内)几乎没有影响。TS 的类型系统编译速度主要瓶颈是联合类型的组合爆炸,而不是递归。一个 10 层的 DeepReadonly 比一个 50 种组合的联合类型快得多。

  • ❌ 误区:「DeepReadonly<Array<T>> 应该返回 ReadonlyArray<Readonly<T>>」 真相:很多人实现 DeepReadonly 时只处理了对象递归,忘了数组。Array<{ a: number }> 经过 [P in keyof T] 会把 0, 1, length, push 等全部变成 readonly,这虽然正确但语义过度。正确的做法是先检查数组,返回 readonly DeepReadonly<T>[]。

  • ❌ 误区:「递归类型能满足任意深度的需求」 真相:TS 对类型实例化有深度限制(默认约 50 层),超了会报 Type instantiation is excessively deep。正常业务对象不会超,但如果你在处理”链表”类型(type List = { val: number; next: List | null })且反复嵌套,可能触及限制。

  • ❌ 误区:「DeepPartial 对所有场景都安全」 真相:如果对象里包含 Set、Map、Promise 等非 plain object,递归会进入这些类型的内部结构,产生意想不到的类型。必须像前面 DeepReadonlyFixed 那样加特殊类型的守卫。

一句话总结

递归类型就是 TypeScript 的”深度优先搜索”——给对象类型做一次 tree walk,在每一层施加你想要的变换(readonly、partial、required),再加上对数组、Map、Date 等特殊类型的边界守卫,你就拥有了运行时 deepClone 的类型级对应物。

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