- 示例工程
【免费下载链接】type-challenges
Collection of TypeScript type challenges with online judge
type-challenges 仓库中的第 296 号中等难度题目 Permutation 要求在不借助运行时、仅靠类型系统运算的前提下,把一个联合类型展开成「包含该联合类型所有排列」的元组联合。本文以该题为核心,系统讲解条件类型分发(distributive conditional types)、递归类型与元组拼接三大核心技法,并给出可一次通过仓库全部测试用例的完整实现,帮助读者掌握类型级「循环 + 分支」的编程范式。
题目速览:将联合类型转换为排列数组
该题目由 Naoto Ikuno(@pandanoir 标明其难度为medium、标签为union。原题描述(韩文版 / 英文版)非常精炼:
주어진 유니언 타입을 순열 배열로 바꾸는 Permutation 타입을 구현하세요. (实现 Permutation 类型,将给定的联合类型转换为排列数组。)
题目给出的唯一示例:
type perm = Permutation<'A' | 'B' | 'C'>; // ['A', 'B', 'C'] | ['A', 'C', 'B'] | ['B', 'A', 'C'] | ['B', 'C', 'A'] | ['C', 'A', 'B'] | ['C', 'B', 'A']也就是说,输入'A' | 'B' | 'C'这样的三成员联合,输出应当是 3! = 6 个元组的联合,覆盖全部排列。仓库为该题准备的起始模板 template.ts 只有一行占位实现:
type Permutation<T> = any评测则由 test-cases.ts 中的Equal断言驱动,全部用例通过才视为完成。
前置知识:条件类型的分发(分布)行为
实现全排列最关键的前提是理解 TypeScript 条件类型的一个隐藏规则:当条件类型T extends X ? A : B中的T是裸类型参数(naked type parameter),且实例化时被传入联合类型时,条件类型会对联合的每个成员分别求值,再把结果合并回联合。这一行为称为“分发”(distribution)。
例如:
type ToArray<T> = T extends any ? T[] : never type R = ToArray<'A' | 'B'> // 'A'[] | 'B'[],而不是 ('A' | 'B')[]正因为分发机制,条件类型在类型层面具备了“遍历联合类型每个成员”的能力——这正是递归全排列的“循环入口”。本仓库的工具定义 utils/index.d.ts 中经典的UnionToIntersection也建立在该机制之上:
export type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never它利用U extends any强制分发,把每个联合成员放进函数参数位置再取交集。可以看出,理解分发是打通这类 union 题目(如 00055-hard-union-to-intersection、00730-hard-union-to-tuple)的共同钥匙。
逐步推导:从单元素到完整排列
第一步:利用分发“拆开”联合类型
先写一个最朴素的版本,让分发机制为联合的每个成员产出一个单元素元组:
type Permutation<T> = T extends any ? [T] : never type P = Permutation<'A' | 'B' | 'C'> // ['A'] | ['B'] | ['C']方向是对的——分发把'A' | 'B' | 'C'拆成了三个分支。但全排列还要求每个元素后面能接上“剩余元素的所有排列”,即递归:
['A', ...Permutation<'B' | 'C'>]第二步:用Exclude<U, T>构造“剩余元素”
递归时,需要从原始联合中去掉当前已选中的元素。若直接在分发后的分支里写Exclude<T, T>,此时T已被收窄为单个成员,Exclude<T, T>恒为空,无法得到剩余元素。解决办法是用一个默认类型参数快照原始联合:
type Permutation<T, U = T> = T extends U ? [T, ...Permutation<Exclude<U, T>>] : neverU = T在类型实例化时把完整的原始联合固化下来;T extends U触发分发,使T依次成为每个成员;Exclude<U, T>则从快照中剔除当前成员,得到递归所需的“剩余联合”。仍以'A' | 'B' | 'C'为例,展开后的形状是:
'A'分支:['A', ...Permutation<'B' | 'C'>]→['A', 'B', 'C'] | ['A', 'C', 'B']'B'分支:['B', ...Permutation<'A' | 'C'>]→['B', 'A', 'C'] | ['B', 'C', 'A']'C'分支:['C', ...Permutation<'A' | 'B'>]→['C', 'A', 'B'] | ['C', 'B', 'A']
三个分支合并正是题目期望的 6 个排列元组。
第三步:兜底never——别让分发吞掉边界
递归的终止条件藏在never上。当Exclude<U, T>为空时,递归调用变成Permutation<never>。此时若直接写T extends U ? ... : never,由于T extends ...是裸类型参数条件且T = never,分发规则会直接返回never——分发把never当作“空联合”处理,条件类型整体得到never,而不是我们想要的[]。
解决方法是把T包进元组,禁止分发:
[T] extends [never] ? [] : ...[never] extends [never]是普通的(非分发)元组结构比较,结果为真,从而正确返回[],为递归画上句号。
最终实现
type Permutation<T, U = T> = [T] extends [never] ? [] : T extends U ? [T, ...Permutation<Exclude<U, T>>] : never对照模板 template.ts 中的type Permutation<T> = any,将any替换为上述实现即可。
用仓库测试用例逐条验证
test-cases.ts 共给出 5 组断言,覆盖了单元素、多元素、乱序输入、布尔与never五种场景:
| 用例 | 输入 | 期望输出 | 说明 |
|---|---|---|---|
| 1 | 'A' | ['A'] | 单元素联合只有一种排列 |
| 2 | 'A' | 'B' | 'C' | 6 个排列元组的联合 | 标准三元素全排列 |
| 3 | 'B' | 'A' | 'C' | 与用例 2 相同的 6 个元组 | 验证结果与成员书写顺序无关 |
| 4 | boolean | [false, true] | [true, false] | 布尔类型按true \| false参与分发 |
| 5 | never | [] | 空联合应返回空元组 |
各用例对应的实现行为:
- 用例 1:
[T] extends [never]为假,T extends U分发后只有'A'一个分支,Exclude<'A', 'A'>为空,递归返回[],拼出['A']。 - 用例 2、3:集合意义上
'B' | 'A' | 'C'与'A' | 'B' | 'C'是同一个联合,因此无论分发顺序如何,产出的 6 个元组集合完全相同,Equal断言成立——这也是实现不依赖成员书写顺序的原因。 - 用例 4:
boolean在严格模式下等价于true | false,分发会依次产生[true, ...Permutation<false>]与[false, ...Permutation<true>],最终得到[false, true] | [true, false]。 - 用例 5:
[never] extends [never]命中兜底分支,直接返回[],不会因分发机制产生never。
所有断言均使用 utils/index.d.ts 中基于函数参数逆变比较实现的严格相等类型Equal,任何多余、缺失或顺序错误的排列都会导致编译失败,验证非常严格。
关键细节深挖
为什么U必须通过默认参数传入?
type Permutation<T, U = T>中,U在实例化Permutation<'A' | 'B' | 'C'>时被绑定为完整联合。若去掉U改用Exclude<T, T>,由于分发后T已是单一成员,Exclude结果恒为空,排列永远无法展开。快照技巧是本题的灵魂,同样适用于仓库中其他需要“记住整体、逐个消费”的类型题(如 08987-medium-subsequence、21220-medium-permutations-of-tuple)。
为什么never判定要写[T] extends [never]?
裸类型参数上的条件类型对never同样会“分发”——never extends U直接得到never,整个分支不可达。把T包进元组([T])后不再触发分发,[never] extends [never]才能被求值为真。这一元组包裹技法也是判别类型是否为never的通用模式,仓库中 01042-medium-isnever 一题正是它的直接应用。
为什么结果是元组联合而不是元组?
每一层递归的分支[T, ...Permutation<...>]本身就是分发合并的结果:外层分支按联合成员展开、内层递归也按剩余成员展开,最终整个类型表达式的所有路径被合并成一个“排列元组”的联合。这恰好符合题面“包含所有排列的数组的联合”的语义,也是类型层“枚举全部可能性”的典型表达。
扩展阅读:排列思想在仓库中的进阶应用
掌握 Permutation 后,可以继续挑战仓库中与其同源、层层递进的题目:
- 21220-medium-permutations-of-tuple:输入从联合类型换成元组,仍需生成全部排列,但拆分与重组的目标变成元组结构,通常需要配合
T[number]把元组转成联合再复用本思路; - 04260-medium-nomiwase(AllCombinations):由排列放宽为组合,允许任意长度、任意顺序的拼接,本质是同一套「分发 + 快照 + 递归」框架;
- 00730-hard-union-to-tuple:反向操作,把联合类型稳定地转换成元组,难点同样在于抑制
never分发与保持顺序稳定; - 00055-hard-union-to-intersection:基于分发机制在函数逆变位置做交集,可视为对分发行为另一面的考察。
在本地仓库中验证解法
本仓库根目录 README.md 提供了本地玩法:克隆仓库并安装依赖后,运行pnpm generate即可将题目生成为本地可玩的 TypeScript 文件,之后在任何带 TypeScript 语言服务的 IDE 中打开 test-cases.ts,若类型推断无红色报错,即代表实现通过了全部Expect<Equal<...>>断言。仓库本身运行在严格模式(strict)下(见 tsconfig.base.json 与根 tsconfig.json),这保证了boolean会被视为true | false这样的双成员联合,用例 4 的成立依赖于此。
小结
Permutation 是一道“小而全”的中等难度题:它只用三个语言特性——条件类型分发、递归类型、元组展开(spread),就完整实现了类型级的排列算法。解题过程中的三个关键决策(快照原始联合、元组包裹阻断分发、Exclude逐成员消耗)分别对应着类型编程中「记住状态」「处理空集」「迭代推进」的通用方法论。吃透这道题,也就掌握了类型系统中编写递归算法与处理边界情况的完整套路,后续挑战组合、子序列、元组排列等进阶题目都将事半功倍。
- 示例工程
【免费下载链接】type-challenges
Collection of TypeScript type challenges with online judge
相关推荐
TypeScript 联合类型转交叉类型:type-challenges 55 UnionToIntersection 源码级拆解
TypeScript 联合类型转交叉类型:type challenges 55 UnionToIntersection 源码级拆解 本篇以 type chall
示例工程深度解析 TypeScript 联合类型转交叉类型:type-challenges 第 55 题 UnionToIntersection 实现原理
深度解析 TypeScript 联合类型转交叉类型:type challenges 第 55 题 UnionToIntersection 实现原理 联合类型(U
示例工程掌握TypeScript高级类型:UnionReplace挑战完全解析指南
掌握TypeScript高级类型:UnionReplace挑战完全解析指南 Type Challenges是一个专注于提升TypeScript和泛型编程能力的学
示例工程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考