【TS】day17-recursive-gymnastics

作者:mario 发布时间: 2026-08-31 阅读量:4 评论数:0

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>](少了展开)
// → 数组里嵌数组,形状错误 ⚠

最佳实践清单

  1. 递归题先画树:纸面模拟前两轮 + 终止轮,再写代码

  2. 判等必挂 Equal:Includes 教训

  3. 递归三定律写进笔记首页:终止 / 缩小 / 产出位

  4. 映射元组记住口诀:“映射尊重输入形状——元组进元组出”

  5. 每天默写昨天的题(铁律 2 持续生效)


九、自测挑战

Q1:递归三定律是什么?各防止什么问题?

Q2IncludesL extends T[number] 的第一版为什么错?涉及哪两个类型的陷阱?

Q3Promise.all 签名里 readonly [...T] 的三个成分各起什么作用?

Q4:映射类型作用在元组上是什么行为?作用在普通数组上呢?

Q5DeepFlatten<[1, [2]]> 的完整执行过程分几轮?每轮的 F 和 R 是什么?

Q6TupleToUnion 只有一行,为什么被标为 medium?这说明体操考察的两种能力是什么?

Q7:手写 DeepFlatten(不看第五节),并给 [1, [2, [3]]] 画递归树。

Q8{ [K in keyof T]: DeepValues<T[K]> }[keyof T] 这个组合拳里,映射做什么、[keyof T] 做什么?

Q9LeafPaths<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 难度的半壁江山已在手中。


延伸阅读

资源

说明

Conditional Types - 递归示例

官方文档尾部的递归讨论

type-challenges 题号:898 / 10 / 20

今日题目

Awaited 内置类型源码

官方递归类型的参考实现


下一步

明天(Day 18)进入字符串体操:模板字面量 + infer 的组合被称为"类型层的正则引擎"——Trim、Replace、ReplaceAll、事件名解析,全部一行模式匹配 + 递归啃食。


递归和洋葱一样:看到木块(终止条件)之前,一层一层地拆下去。

每天花 2 小时,28 天通关 TypeScript 深入。第 3 周第 3 天,两道 medium 到手!


评论