递归类型实现深度解析
递归类型让 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 的思路实现的。
其实你每天都在用
Object.freeze的类型缺失——JS 的Object.freeze只冻结第一层,但 TypeScript 的Readonly<T>也只标注第一层只读。DeepReadonly<T>填补了这个差距,让你能在类型层面保证”真正冻结”。- React
useState的组合状态——setConfig(prev => ({ ...prev, db: { ...prev.db, host: newHost } })),如果你在 TypeScript 中用Readonly<Config>包裹,prev.db.host = ...就会报错(因为db不是 readonly)。DeepReadonly让深层不可变也能被类型系统检查。 - GraphQL 查询结果类型——Apollo 的
useQuery返回的data类型实际上就是沿着查询 AST 递归构建的。你写了三层嵌套{ user { posts { title } } },返回就是{ user: { posts: { title: string }[] } }——类型系统递归地从 schema 映射到查询结果。 - immer 的
produce类型——produce(state, draft => { draft.nested.value = 5 })的draft类型是深度可写的镜像类型,本质是DeepWritable<T>(递归移除 readonly)。immer 本身依赖这个类型推导。 - 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 的类型级对应物。