TypeScript 递归体操 — 类型的自我调用,从 Includes 到 Promise.all
昨天的元组题里,
Last2的"歪解"已经出现过递归的影子。今天把它扶正:递归条件类型是类型体操从"入门"到"进阶"的分水岭——打平、搜索、全排列、深遍历,全部靠它。主菜是两道 medium:Includes(严格判等 + 递归扫描)和Promise.all(元组同态映射的传世经典)。
目录
一、为什么递归是分水岭
1.1 不用递归能做什么
// 所有"一轮就能算完"的变换:
type A = Head<[1, 2, 3]>; // 取个头 —— 一轮
type B = Exclude<"a" | "b", "b">; // 过滤联合 —— 一轮(分配律自动拆)
type C = Last<[1, 2, 3]>; // 取个尾 —— 一轮
// 共同点:输出规模固定,与输入规模无关
1.2 递归解锁了什么
// 输出规模与输入规模"联动"的变换,全部需要递归:
// 深度未知 → 递归深入
type Flatten<T extends readonly any[]> = /* ... */;
type F = Flatten<[1, [2, [3, [4]]]]>; // [1, 2, 3, 4]
// 数量未知 → 递归扫描
type Includes<T extends readonly any[], V> = /* ... */;
type I = Includes<[1, 2, 3, 4, 5], 4>; // true
// 层级未知 → 递归遍历
type DeepReadonly<T> = /* ... */;
// 任意深的嵌套对象全部只读
// 一句话:递归让类型拥有了"循环"的能力——
// 没有循环的类型系统只能做"公式",有循环的才能做"算法"
1.3 第 2 周的伏笔回收
// 你其实已经写过两个递归类型(第 2 周 Day 12):
type MyAwaited<T> = T extends Promise<infer V> ? MyAwaited<V> : T;
// ↑ 自己调用自己
type Flatten<T> = T extends (infer E)[] ? Flatten<E> : T;
// ↑ 自己调用自己
// 今天的工作:把"无意中写对"升级为"刻意掌握"
二、递归三定律
(命名致敬 CS61A 的"递归三定律"——类型层完全适用)
定律 1:必须有终止条件
// 什么时候停?——形状不再匹配的时候,走 else 分支
type Flatten<T> = T extends (infer E)[] ? Flatten<E> : T;
// ↑ 继续拆 ↑ 终止:不是数组就返回
// ⚠ 反例:永不终止
type Loop<T> = T extends object ? Loop<T> : T;
// ↑ T 是 object 时永远还是 object —— 死循环!
// 编译器报错:Type instantiation is excessively deep
定律 2:每轮必须缩小规模
// 递归调用处的参数,必须比"当前输入"更小/更简单:
// 数组:剥掉一层皮
type Scan<T extends readonly any[]> =
T extends [infer F, ...infer R] ? ... : ...;
// ↑ R 比 T 少一个元素 ✅
// 字符串:啃掉一个字符
type Chars<S extends string> =
S extends `${infer C}${infer Rest}` ? ... : ...;
// ↑ Rest 比 S 短一个字符 ✅
// 联合:Exclude 掉一个成员
type Permutation<T, U = T> = ... Exclude<U, T> ...;
// ↑ 剩余成员比原来少 ✅(Day 21 见)
定律 3:递归调用必须出现在"产出位置"
// 正确姿势:递归的结果直接成为结果的一部分
type Flatten<T extends readonly any[]> =
T extends [infer F, ...infer R]
? [...(F extends readonly any[] ? Flatten<F> : [F]), ...Flatten<R>]
: [];
// ↑ 递归调用嵌在"数组构造"里 —— TS 能展开整个计算
// 这条定律更像"经验规律":产出位置的递归最不容易触发深度限制
三、今日题目逐个击破
3.1 第 898 题 · Includes(medium · 今日主菜一)
// 题目:实现 Includes<T, L>
// Includes<[1, 2, 3], 2> → true
// Includes<[{ a: 1 }], { a: 1 }> → true(对象引用也要相等!)
// Includes<[any], 1> → false ⭐(any 的坑!)
// Includes<[never], never> → false ⭐(never 的坑!)
// ===== 先自己写 15 分钟 =====
// 大多数人的第一版:
type IncludesBad<T extends readonly any[], L> =
L extends T[number] ? true : false;
// 看似合理,实则两处崩塌:
// 1. T = [any] 时,T[number] = any,1 extends any = true ⚠(用例要求 false)
// 2. 对象成员:{ a: 1 } extends { a: 1 } 是 true
// 但两个"结构相同"的对象在用例里也可能要求 false(内部同一性)
// ===== 正解:Equal 判等 + 递归扫描 =====
type Equal<X, Y> =
(<T>() => T extends X ? 1 : 2) extends
(<T>() => T extends Y ? 1 : 2) ? true : false;
type Includes<T extends readonly any[], L> =
T extends [infer F, ...infer R] // 拆出头和尾(五虎将句式)
? Equal<F, L> extends true
? true // 头匹配 → 找到了
: Includes<R, L> // 头不匹配 → 递归扫剩余
: false; // 空元组 → 没找到,终止
本题的两层教学意义:
第一层(机制):递归扫描的标准范式
拆头 → 处理头 → 递归尾 → 空时终止
第二层(认知):类型判等的复杂性
extends 有 any/never/结构相等三重陷阱
"判等"在类型层是个困难问题 —— Equal 是唯一可靠武器
3.2 第 10 题 · TupleToUnion(medium · 一行题的教学)
// 题目:TupleToUnion<[1, 2, 3]> → 1 | 2 | 3
type TupleToUnion<T extends readonly any[]> = T[number];
为什么 medium 难度标在一行题上? 因为它考的是"知不知道"而非"会不会推理"——T[number] 索引访问的深度理解(Day 16 冷知识 2)。体操的真相:一半是推理,一半是见识。
3.3 第 20 题 · Promise.all(medium · 今日主菜二)
// 题目:实现 PromiseAll 的类型
// PromiseAll<[Promise<number>, number]> → Promise<[number, number]>
// PromiseAll<[Promise<string>, Promise<number>]> → Promise<[string, number]>
// ===== 答案 =====
declare function PromiseAll<T extends readonly any[]>(
values: readonly [...T]
): Promise<{
[K in keyof T]: Awaited<T[K]>;
}>;
3.4 逐层拆解(本题值得拆一整节)
// ===== 疑点 1:参数为什么是 readonly [...T] 而不是 T? =====
// 如果直接写 values: T:
PromiseAll([Promise.resolve(1), 2]);
// T 被推断为 (Promise<number> | number)[] ⚠
// —— 普通"可变数组"的元素类型被联合化,位置信息丢失!
// 写 values: readonly [...T]:
// readonly 前缀 → 调用方传数组字面量时按"元组"推断
// [...T] 展开 → 保留每个位置的类型
// T = readonly [Promise<number>, number] ⭐ 每个位置精确
// ===== 疑点 2:返回值为什么是映射类型?它怎么变成元组的? =====
// 核心冷知识:映射类型作用在元组上 = 逐元素变换(保持元组形状)!
type Mapped = { [K in keyof ["a", "b"]]: `v_${string & K}` };
// 悬停看结果:[`v_0`, `v_1`] —— 还是元组!长度不变,逐元素加工
// 所以:
// { [K in keyof T]: Awaited<T[K]> }
// 对 readonly [Promise<number>, number] 做"逐元素 Awaited"
// = [Awaited<Promise<number>>, Awaited<number>]
// = [number, number] ⭐ 完美
Promise.all 是"元组同态映射"的教科书——这个模式在 Axios 拦截器、React Query 的 select 类型里反复出现。
四、元组同态映射:Promise.all 的灵魂
单独把这个机制拎出来练透(它还会在 Day 19 出场):
// ===== 实验组:映射元组的各种玩法 =====
// 实验 1:基础逐元素变换
type Map1 = { [K in keyof [string, number]]: boolean };
// [boolean, boolean]
// 实验 2:结合索引访问(元素自己参与计算)
type Map2 = { [K in keyof [1, 2, 3]]: [`第${K}个`, 1|2|3[K]] };
// [["第0个", 1], ["第1个", 2], ["第2个", 3]] ⭐
// 实验 3:为什么对象映射不会"变成对象"而元组保持元组?
// TS 的设计:映射类型检查输入形状——
// 输入是元组 → 产出元组(同态)
// 输入是对象 → 产出对象
// 输入是联合 → 产出联合(分配)
// "映射类型尊重输入的容器形状" —— 同态(homomorphic)的本义
// 实验 4:与普通数组的区别
type Map3 = { [K in keyof string[]]: boolean };
// 结果不是 boolean[]!而是带数组方法的奇怪对象 ⚠
// 元组同态映射只对"元组"生效,普通数组没有位置信息可映射
五、双重递归:DeepFlatten 详解
// 题目(自命题,难度 medium):深度打平元组
// DeepFlatten<[1, [2, [3, [4]]]]> → [1, 2, 3, 4]
type DeepFlatten<T extends readonly any[]> =
T extends [infer F, ...infer R]
? F extends readonly any[]
? [...DeepFlatten<F>, ...DeepFlatten<R>] // 分支 A
: [F, ...DeepFlatten<R>] // 分支 B
: []; // 分支 C(终止)
5.1 逐轮执行模拟(脑内模拟是最好的练习)
输入:DeepFlatten<[1, [2, [3]]]>
第 1 轮:T = [1, [2, [3]]]
拆头尾:F = 1,R = [[2, [3]]]
F = 1 不是数组 → 分支 B
结果 = [1, ...DeepFlatten<[[2, [3]]]>]
第 2 轮:T = [[2, [3]]]
拆头尾:F = [2, [3]],R = []
F 是数组 → 分支 A
结果 = [...DeepFlatten<[2, [3]]>, ...DeepFlatten<[]>]
第 3 轮:T = [2, [3]]
拆头尾:F = 2,R = [[3]]
F 不是数组 → 分支 B
结果 = [2, ...DeepFlatten<[[3]]>]
第 4 轮:T = [[3]]
F = [3] 是数组 → 分支 A
结果 = [...DeepFlatten<[3]>, ...DeepFlatten<[]>]
第 5 轮:T = [3]
F = 3 不是数组 → 分支 B
结果 = [3, ...DeepFlatten<[]>]
第 6 轮:T = []
不匹配 [infer F, ...infer R] → 分支 C:[]
回溯组装(从最深一层往外拼):
DeepFlatten<[3]> = [3]
DeepFlatten<[3]> 拼 DeepFlatten<[]> = [3, ...[]] = [3]
DeepFlatten<[2, [3]]> = [2, ...[3]] = [2, 3]
DeepFlatten<[[2, [3]]]> = [2, 3, ...[]] = [2, 3]
最终:[1, ...[2, 3]] = [1, 2, 3] ⭐ ... 加减分支 C 的空元组贡献 []
把这个模拟过程抄进笔记——"画递归树"是 hard 题的必备技能。
5.2 "双重递归"的结构
DeepFlatten 同时对"头"和"尾"递归:
头是数组 → 头自己也要打平(深度方向的递归)
尾永远递归(广度方向的递归)
[1, [2, [3]]]
/ \
1 [[2, [3]]] ← 广度递归(剥尾)
(叶子) / \
[2, [3]] []
/ \
2 [[3]] ← 深度递归(打平头)
(叶子) / \
[3] []
/ \
3 [] ← 到达叶子
六、工业实战场景
6.1 场景一:嵌套配置的深层取值
/**
* 大屏主题配置深水区取值(第 2 周的 DeepValues,用今天的视角重看)
*/
interface Theme {
colors: { primary: string; danger: string };
chart: { line: { width: number; smooth: boolean } };
}
/** 递归收集嵌套对象的所有"叶子值类型" */
type DeepValues<T> = T extends object
? { [K in keyof T]: DeepValues<T[K]> }[keyof T]
: T;
type AllValues = DeepValues<Theme>;
// string | number | boolean ⭐ 无论嵌套多深,叶子类型全部收集
// 应用:序列化函数只接受"叶子类型"
function serializeLeaf(value: DeepValues<Theme>): string {
return String(value);
}
拆解:{ [K in keyof T]: ... }[keyof T] 是"映射再索引"的组合拳——映射产出每层的变换对象,[keyof T] 立刻取所有值的联合(Day 16 的冷知识 2 在对象上的版本)。
6.2 场景二:多层菜单的路由收集
/**
* 后台管理系统的菜单树:递归收集全部叶子路由
* (Get<T, Path> 的 Day 19 前菜)
*/
interface MenuItem {
path: string;
children?: MenuItem[];
}
type MenuTree = [
{ path: "/dashboard"; children: [{ path: "/dashboard/main" }] },
{ path: "/settings"; children: [{ path: "/settings/profile" }, { path: "/settings/security" }] }
];
/** 递归收集所有叶子 path(数组长度未知 → 递归) */
type LeafPaths<T> =
T extends readonly { path: infer P; children?: infer C }[]
? C extends readonly unknown[]
? P | LeafPaths<C>
: P
: never;
type Paths = LeafPaths<MenuTree>;
// "/dashboard/main" | "/settings/profile" | "/settings/security" ⭐
// 路由注册函数只接受合法叶子路由:
function registerRoute(path: Paths): void {}
registerRoute("/settings/profile"); // ✅
registerRoute("/settings/typo"); // ❌ 编译期拦截
6.3 场景三:异步数据加载器的类型编排
/**
* 大屏页面的"并行加载":多个接口同时请求,全部到位后渲染
* (Promise.all 模式的业务落地)
*/
declare function fetchDevices(): Promise<Device[]>;
declare function fetchStats(): Promise<DeviceStats>;
declare function fetchAlerts(): Promise<AlertRecord[]>;
/** 并行加载器:返回值类型与加载列表一一对应 */
async function loadPageData(): Promise<
[Device[], DeviceStats, AlertRecord[]]
> {
return Promise.all([fetchDevices(), fetchStats(), fetchAlerts()]);
}
const [devices, stats, alerts] = await loadPageData();
// devices: Device[]、stats: DeviceStats、alerts: AlertRecord[]
// ⭐ 解构即正确类型——没写一行手动标注
七、类比记忆:俄罗斯套娃 vs 接力赛
递归条件类型 = 拆俄罗斯套娃
┌──────────────────────────────────────┐
│ 每层套娃(元组/对象/字符串) │
│ 拆开 → 里面可能是更小的套娃(递归) │
│ → 也可能是实心木块(终止返回) │
│ 拆到底,再从最里层往外组装结果 │
│ │
│ 三定律: │
│ 1. 见到木块就停(终止条件) │
│ 2. 每次只能拆一层(缩小规模) │
│ 3. 拆出的东西要留着拼回去(产出位置) │
└──────────────────────────────────────┘
Includes 的递归 = 接力赛
┌──────────────────────────────────────┐
│ 第一棒(头元素):是我吗? │
│ 是 → 比赛结束(true) │
│ 不是 → 交给下一棒(递归尾元组) │
│ 最后一棒是空元组:没人接了(false) │
└──────────────────────────────────────┘
八、常见坑点与最佳实践
坑点 1:忘记 any/never 的判等陷阱
// Includes 的教训(再强调一次):
type X = any extends string ? true : false; // true ⚠
type Y = never extends string ? true : false; // true ⚠(never 万物皆匹配)
// 涉及"成员判等"的题,第一反应挂 Equal
坑点 2:递归无终止导致的"excessively deep"
// 报错信息:Type instantiation is excessively deep and possibly infinite
// 排查三连:
// 1. else 分支存在吗?(终止条件)
// 2. 递归参数变了吗?(T extends object ? Loop<T> —— 参数没变 = 死循环)
// 3. 递归在产出位吗?(挪进 [...递归结果, ...] 试试)
坑点 3:映射元组时误用于普通数组
// 元组同态映射只对元组生效:
type A = { [K in keyof [1, 2]]: boolean }; // [boolean, boolean] ✅
type B = { [K in keyof number[]]: boolean }; // ⚠ 含数组方法键的怪对象
// 题目/业务里如果输入可能是普通数组,先约束:
type MapTuple<T extends readonly any[]> = { [K in keyof T]: Boolean };
坑点 4:Promise.all 的 readonly […T] 写错
// 常见错误写法与后果:
declare function P1<T extends readonly any[]>(values: T): /* ... */;
// ↑ 少了 readonly [...T]
// P1([Promise.resolve(1), 2]) → T 推断为 (Promise<number> | number)[]
// 位置信息丢失 → 返回类型全乱
// 正确:values: readonly [...T] —— 三个成分缺一不可
// T(捕获泛型)+ readonly(触发元组推断)+ [...T](保留位置)
坑点 5:DeepFlatten 忘了空元组的贡献
// 分支 A 的完整形态:
[...DeepFlatten<F>, ...DeepFlatten<R>]
// ↑ R 可能是 []!展开 [] 贡献 0 个元素——正是我们想要的
// 如果写成 [...DeepFlatten<F>, DeepFlatten<R>](少了展开)
// → 数组里嵌数组,形状错误 ⚠
最佳实践清单
递归题先画树:纸面模拟前两轮 + 终止轮,再写代码
判等必挂 Equal:Includes 教训
递归三定律写进笔记首页:终止 / 缩小 / 产出位
映射元组记住口诀:“映射尊重输入形状——元组进元组出”
每天默写昨天的题(铁律 2 持续生效)
九、自测挑战
Q1:递归三定律是什么?各防止什么问题?
Q2:Includes 用 L extends T[number] 的第一版为什么错?涉及哪两个类型的陷阱?
Q3:Promise.all 签名里 readonly [...T] 的三个成分各起什么作用?
Q4:映射类型作用在元组上是什么行为?作用在普通数组上呢?
Q5:DeepFlatten<[1, [2]]> 的完整执行过程分几轮?每轮的 F 和 R 是什么?
Q6:TupleToUnion 只有一行,为什么被标为 medium?这说明体操考察的两种能力是什么?
Q7:手写 DeepFlatten(不看第五节),并给 [1, [2, [3]]] 画递归树。
Q8:{ [K in keyof T]: DeepValues<T[K]> }[keyof T] 这个组合拳里,映射做什么、[keyof T] 做什么?
Q9:LeafPaths<MenuTree> 的递归在哪两个方向上发生?
Q10:什么信号告诉你一道题需要递归?(用"规模联动"的角度回答)
十、总结与知识图谱
递归体操(Day 17)
│
├── 认知
│ ├── 分水岭:递归 = 类型层的循环(公式 → 算法)
│ └── 触发信号:输出规模与输入规模联动
│
├── 递归三定律
│ ├── 终止条件(else 分支)
│ ├── 缩小规模(剥皮/啃字符/Exclude 成员)
│ └── 产出位置(递归结果嵌在构造里)
│
├── 题目战绩
│ ├── Includes(898):Equal + 接力扫描
│ ├── TupleToUnion(10):T[number] 一行题
│ ├── Promise.all(20):readonly [...T] + 元组同态映射
│ └── DeepFlatten(自命题):双重递归
│
├── 元组同态映射(新机制)
│ ├── 映射尊重输入形状:元组进元组出
│ ├── [K in keyof T]: 变换<T[K]>(逐元素加工)
│ └── 普通数组无位置信息,不适用
│
└── 工业落地
├── DeepValues:嵌套配置叶子收集
├── LeafPaths:菜单树路由收集
└── loadPageData:并行加载类型编排
一句话总结:递归三定律(终止、缩小、产出位)是类型层循环的语法,Equal 判等是搜索类题的通行证,元组同态映射是批量变换的隐形引擎——三者组合,medium 难度的半壁江山已在手中。
延伸阅读
下一步
明天(Day 18)进入字符串体操:模板字面量 + infer 的组合被称为"类型层的正则引擎"——Trim、Replace、ReplaceAll、事件名解析,全部一行模式匹配 + 递归啃食。
递归和洋葱一样:看到木块(终止条件)之前,一层一层地拆下去。
每天花 2 小时,28 天通关 TypeScript 深入。第 3 周第 3 天,两道 medium 到手!