如何用 TypeScript 折腾出全排列之你不知道的 :“分布式条件类型”、“递归处理”

科学家说:复杂系统的美在于简洁的规则。

工程师说:我只希望编译器别红线。

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)

编译器不懂浪漫,但我们可以在它的语义空间里,创造出一种精致的秩序。

至于优雅?让没有红线的代码自己去证明吧。✨

------ 完 ------

相关推荐
十八朵郁金香2 小时前
深入理解 SSE:服务器发送事件及其在前后端中的实践
前端·javascript
加油乐2 小时前
Sass与Less的特性与区别
前端·css
鹏多多3 小时前
React自定义Hooks设计指南:从封装到复用
前端·javascript·react.js
JarvanMo3 小时前
在 Flutter 中正确处理文本缩放
前端
深蓝电商API3 小时前
HTML 解析入门:用 BeautifulSoup 轻松提取网页数据
前端·爬虫·python·beautifulsoup
excel3 小时前
JavaScript 运算符与 Vue 中的 1 << n 应用
前端
上单带刀不带妹3 小时前
Vue3 全局 API 转移详解
前端·javascript·vue.js·vue3·api
怕冷的火焰(~杰)3 小时前
yarn安装electron和better-sqlite3失败问题(rebuild:better-sqlite3)
前端·javascript·electron
IT_陈寒4 小时前
JavaScript性能优化:7个90%开发者不知道的V8引擎黑科技
前端·人工智能·后端