TypeScript 递归类型适合什么场景?为什么会遇到深度限制?
简化版
递归类型是在类型定义里引用自身,适合描述树、JSON、菜单、路由、嵌套数组、深度只读等嵌套结构。
它也常用于类型工具,比如 DeepPartial、DeepReadonly。但递归类型过深或分支过多会让编译器实例化成本变高,甚至报“类型实例化过深”的错误。
详细版
type TreeNode = {
id: string;
children?: TreeNode[];
};
深度工具类型:
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? DeepReadonly<T[K]>
: T[K];
};
这种写法能处理嵌套对象,但遇到函数、数组、Date、Map 等对象时需要额外分支。复杂递归类型要控制深度和范围,不要为了类型完美拖慢整个项目。
完整版教学
一、递归类型描述嵌套结构
最自然的场景是树。
type MenuItem = {
label: string;
path: string;
children?: MenuItem[];
};
一个菜单项的 children 仍然是菜单项数组,这就是递归。
递归类型不是高级题专属,树形 UI、路由和评论楼中楼都在用它。
二、JSON 类型也是递归结构
type Json =
| string
| number
| boolean
| null
| Json[]
| { [key: string]: Json };
这能表达 JSON 可以嵌套数组和对象。
| JSON 值 | 是否符合 |
|---|---|
"a" | 是 |
[1, {"x": true}] | 是 |
() => 1 | 否 |
三、递归工具类型的基本套路
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
这个类型会把对象所有层级都变成可选。
type Config = {
db: { host: string; port: number };
};
type PartialConfig = DeepPartial<Config>;
四、object 分支不等于普通对象
object 包含数组、函数、Date、Map 等非原始值。
type Bad<T> = T extends object ? DeepPartial<T> : T;
如果不做特殊处理,数组和函数可能被错误展开。
实际工具类型通常会先处理函数、数组、内置对象,再递归普通对象。
五、为什么会有深度限制
编译器需要不断实例化类型。
Deep<A>
-> Deep<B>
-> Deep<C>
-> ...
如果递归没有出口,或联合分支不断膨胀,类型检查成本会快速上升。50 层嵌套对象再叠加多个联合分支,可能让编辑器明显变慢。
六、如何控制复杂度
常见做法:
- 明确递归出口
- 限制递归层数
- 避免大联合分发
- 对数组、函数、Date 做特殊分支
- 不追求所有运行时形态都由类型精确表达
type Depth = 0 | 1 | 2 | 3;
复杂类型工具可以引入深度参数,但这也会增加阅读成本。
七、常见误区与追问
- 误区:递归类型只用于类型体操。 树、菜单、路由、JSON 都是实际场景。
- 误区:
T extends object就只匹配普通对象。 数组、函数和 Date 也会被包含。 - 误区:递归类型越精确越好。 太复杂会拖慢编译和编辑器提示。
- 误区:递归不需要出口。 没有清晰出口会导致实例化过深。
- 追问:DeepPartial 有哪些坑? 数组、函数、内置对象和联合类型都要额外处理。
- 追问:类型深度报错怎么处理? 简化类型、加出口、限制深度或拆分工具类型。
八、加强记忆
记忆方式:
nested data -> recursive type
deep utility -> recursive mapped type
too deep -> compiler cost
回答时先给树和 JSON 两个例子,再讲 DeepPartial,最后提醒 object 分支和深度限制,是比较完整的答法。