科学家说:复杂系统的美在于简洁的规则。
工程师说:我只希望编译器别红线。
TypeScript 说:要不...我们在类型里递归一下?🙂
今天我们拆解一个经典的"类型体操"题,它也是理解 TypeScript 条件类型与联合类型分发机制的最佳入口。主角只有一行:
ini
type Permutation<T, K = T> =
[T] extends [never]
? []
: K extends K
? [K, ...Permutation<Exclude<T, K>>]
: never
别急,让我们像逆向编译一样,一层层剥开它的心智模型和底层规则。
0. 热身题:我们要得到什么?
目标:给一个联合类型 T,例如 'A' | 'B' | 'C',生成所有排列(全排列)的"元组联合"。
- Permutation<'A' | 'B'> => ['A', 'B'] | ['B', 'A']
- Permutation<1 | 2 | 3> => [1,2,3] | [1,3,2] | [2,1,3] | [2,3,1] | [3,1,2] | [3,2,1]
换句话说,我们要在"类型层面"算出排列,而不是在运行时。因为这能让你的 API 类型提示"预知未来",对每一种顺序都给出精确校验。
1. 编译器的"魔法棒":联合类型的分发
TypeScript 有个重要规则:如果条件类型的左侧是"裸露的类型参数",它会对联合类型进行分发。
通俗点说:T 是 A | B 时,T extends X ? Y : Z
会被拆成 (A extends X ? Y : Z) | (B extends X ? Y : Z)
。
- 这让我们能"逐个拿到联合成员",像枚举一样地处理每一个分支。
- 这正是全排列的关键:对每个 K,放到第一位,然后对"剩下的"再去排列。
小图标时间:
🎩 => 你以为是一顶帽子,其实它是无限兔子的工厂。
联合类型 + 分发条件类型 = 无限兔子。
2. 终止条件:为什么是 [T] extends [never] ?
如果 T 没东西了(即 T 为 never),那全排列只剩一个空序列:[]。
难点是:如何判断"整个 T 是不是 never"。
- 如果写
T extends never
,当 T 是 A | B 时会"分发",分别判断 A 和 B,自然不是我们想要的"整体为 never 的判断"。 - 用一层方括号套住:
[T] extends [never]
。这会阻止分发,编译器把它当作"整体的 T"来看,只在 T 真的是 never 时为真。
这叫"禁用分发"技巧。
就像把糖果装进盒子里再称重,防止它们被拆散逐个称。
3. 启动分发:为什么写 K extends K?
K extends K ? ... : never
看起来像自言自语,但它是一个经典"触发分发"的手段。
- 默认 K = T,且 K 在这个条件类型里是"裸露"的,因此对于联合类型 T,每个成员都会走一遍这个分支。
- 每个分支里的 K,就代表了"当前挑出来的那个元素",也就是排列里"放在第一位"的人选。
设计巧妙在于:用 K 作为"当前挑选项",而 T 保留为"全集"。
接下来用 Exclude<T, K>
拿到"剩余项",递归生成后续位置的排列。
4. 拼装递归:Exclude 与展开
再看核心一行:
swift
[K, ...Permutation<Exclude<T, K>>]
- 第一个元素是当前分发到的 K(被选中放在第一位)。
- 后面的元素来自"对剩余元素继续做全排列",也就是
Permutation<Exclude<T, K>>
。 Exclude<T, K>
会把联合类型 T 里等于 K 的那一项排掉,得到剩余的候选集合。
所以,对于 T = 'A' | 'B' | 'C':
- 分发时先取 K='A',得到 ['A', ...Permutation<'B' | 'C'>]
- 然后取 K='B',得到 ['B', ...Permutation<'A' | 'C'>]
- 再取 K='C',得到 ['C', ...Permutation<'A' | 'B'>]
- 递归到底时,T=never => []
最终,这些分支的结果被联合起来,就是"所有可能的元组"。
5. 运行时类比:用 JS 复刻思路(仅作心智模型)
这段 JS 只是解释思路,真实类型计算完全发生在编译期。
ini
function permutations(arr) {
if (arr.length === 0) return [[]];
const res = [];
for (const k of arr) {
const rest = arr.filter(x => x !== k);
for (const p of permutations(rest)) {
res.push([k, ...p]);
}
}
return res;
}
// 心智印象:TypeScript 的类型系统在做逻辑上类似的拆分和合成,
// 但它处理的是"联合类型"的成员,而不是运行时数组元素。
相似之处:一个"选当前 + 排剩下"的递归。
不同之处:TS 是在类型层计算,靠分发条件类型展开所有分支;JS 是运行时递归。
6. 浏览器级"底层":never 和联合的"吸收性"
- never 在类型世界里是"空集合"。当我们把 T 缩小到空集时,排列应当是"只有一种空排列",即 []。
- 条件类型遇到 never 通常会直接短路,这正好配合我们做递归终止。
- 联合类型是"并集",分发条件类型就是"对并集的每一个成员都套一遍规则,再把结果并起来"。
你可以把整个过程想象为:
"从一个集合里挑一个,剩下的再递归;当集合为空,返回唯一的空排列。"
7. 类型系统的"精巧副作用":K 的存在价值
为什么不是只写一个参数 T?
因为我们需要"既能判断整体 T 是否为空",又需要"对联合成员逐个分发"。
-
T\] extends \[never\] 用于整体判断,禁止分发。
- 设置默认值 K = T,让它总能覆盖 T 的所有成员,又不需要额外传参。
这就像在算法里同时维护"全集"和"当前元素",但在类型系统里需要借助两个类型参数来表达不同的分发策略。
8. 可视化小卡片:执行树
让我们以 T = 'A' | 'B' | 'C' 为例(用表情模拟展开树):
-
选 A
- 选 B → ['A','B', ...perm('C')] → ['A','B','C']
- 选 C → ['A','C', ...perm('B')] → ['A','C','B']
-
选 B
- 选 A → ['B','A','C']
- 选 C → ['B','C','A']
-
选 C
- 选 A → ['C','A','B']
- 选 B → ['C','B','A']
小图标:
- 🎯 当前选中
- 🪄 分发展开
- 🧩 递归拼装
9. 常见问题与"踩坑排雷"
- "为什么我改成 T extends never 就挂了?"
因为发生了分发,A | B 会被拆开分别判断,不再是"整体为 never"的判断。用 [T] 套一下阻止分发。 - "为什么不直接递归 T 呢?"
你需要同时"整体判断 + 逐个分发",所以分两步:整体用 [T],分发用 K。 - "我能把 never 作为一个元素参与排列吗?"
不行,never 是空集合,不表示某个具体值。 - "复杂度会不会爆炸?"
是的,n 个元素的排列数是 n!。类型展开在大 n 时会很慢。实际工程中谨慎使用,确保 T 的规模很小。
10. 结合实际工程的用法建议
- 用于声明 API 的"顺序敏感的元组参数"。比如某些指令流水、按键组合、动画队列。
- 与映射类型、模式字符串联合使用,可以生成严格的路由段排列。
- 控制规模:对 3~5 个元素很友好;大于 7 基本会让 TS 编译器抱怨人生。
11. 附:互动型"类型游乐园"(仅演示)
试试这些类型别名(粘到你的 ts 文件里,用鼠标悬停查看推导结果):
ini
type Permutation<T, K = T> =
[T] extends [never]
? []
: K extends K
? [K, ...Permutation<Exclude(T, K)>]
: never;
// 小试牛刀
type P2 = Permutation<'A' | 'B'>;
// ^? 期望:['A','B'] | ['B','A']
type P3 = Permutation<1 | 2 | 3>;
// ^? 期望:
// [1,2,3] | [1,3,2] |
// [2,1,3] | [2,3,1] |
// [3,1,2] | [3,2,1]
温馨提示:把鼠标放在 P3 上,IDE 会给你展开一个足够壮观的联合类型"瀑布"。
12. 结语:当类型系统开始"干算法"
Permutation 看似炫技,但它不是炫技的终点。它展示的是:
- 如何控制"分发"与"禁用分发"的开关;
- 如何利用 never 作为递归的地板;
- 如何在"类型层"复现"递归算法"的结构。
当你理解了这行代码,你就同时掌握了 TypeScript 条件类型的两把钥匙:
- 盒子技巧([T])
- 裸露触发(K extends K)
编译器不懂浪漫,但我们可以在它的语义空间里,创造出一种精致的秩序。
至于优雅?让没有红线的代码自己去证明吧。✨
------ 完 ------