联合类型转元组 Union to Tuple
联合类型转元组 Union to Tuple
题目
实现类型 UnionToTuple<T>,将联合类型转换为元组类型。元组中元素的顺序不限——任何排列都可以接受。
UnionToTuple<1> // [1]
UnionToTuple<'any' | 'a'> // ['any', 'a'] 或 ['a', 'any']结果必须是单个元组,而不是元组的联合。
解答
type UnionToIntersection<U> =
(U extends any ? (arg: U) => void : never) extends (arg: infer I) => void
? I
: never
type LastOfUnion<U> =
UnionToIntersection<U extends any ? () => U : never> extends () => infer R
? R
: never
type UnionToTuple<U, Last = LastOfUnion<U>> =
[U] extends [never]
? []
: [...UnionToTuple<Exclude<U, Last>>, Last]这个方案组合了三种技术:联合转交叉、提取联合的最后一个元素和递归构建元组。
逐步拆解
第一步 — UnionToIntersection(来自第 55 题)
type UnionToIntersection<U> =
(U extends any ? (arg: U) => void : never) extends (arg: infer I) => void
? I
: never利用逆变推断将 A | B | C 转换为 A & B & C。(详细解释见第 55 题。)
第二步 — 提取联合的"最后一个"成员
type LastOfUnion<U> =
UnionToIntersection<U extends any ? () => U : never> extends () => infer R
? R
: never这是最巧妙的部分。让我们逐步追踪:
U extends any ? () => U : never将联合分布到函数类型中:A | B | C→(() => A) | (() => B) | (() => C)
UnionToIntersection<...>将其转换为函数交叉:(() => A) & (() => B) & (() => C)
不同返回类型的函数交叉表现为重载函数。当你从返回类型
infer R时,TypeScript 选择最后一个重载的返回类型。所以
R=C(或 TypeScript 认为联合中"最后"的那个成员)。
注意:TypeScript 处理联合成员的顺序是实现细节,规范中没有保证。但它足够一致,使得这个方案能正常工作。
第三步 — 递归构建元组
type UnionToTuple<U, Last = LastOfUnion<U>> =
[U] extends [never]
? []
: [...UnionToTuple<Exclude<U, Last>>, Last]- 基础情况:如果
U是never(空联合),返回[] - 递归情况:从联合中提取
Last,放在元组末尾,用剩余成员递归
[U] extends [never] 检查(用元组包裹)避免了分布行为——裸写的 U extends never 会分布且永远不匹配。
推导过程:
U = 'a' | 'b' | 'c'
第 1 轮:Last = 'c'
→ [...UnionToTuple<'a' | 'b'>, 'c']
第 2 轮:U = 'a' | 'b',Last = 'b'
→ [...UnionToTuple<'a'>, 'b']
第 3 轮:U = 'a',Last = 'a'
→ [...UnionToTuple<never>, 'a']
第 4 轮:U = never
→ []
展开:[...[], 'a'] = ['a']
[...['a'], 'b'] = ['a', 'b']
[...['a', 'b'], 'c'] = ['a', 'b', 'c'] ✓深入分析
为什么用 [U] extends [never] 而不是 U extends never?
// ❌ 这样不行:
type Bad<U> = U extends never ? [] : [U]
type Test = Bad<never> // never(不是 [])never 是空联合。在分布式条件类型(U extends ...)中,它会在零个成员上分布并产生 never。用元组包裹 [U] extends [never] 可以禁用分布。
利用重载获取"联合的最后一个"
当 TypeScript 遇到函数类型的交叉时,会把它们当作重载。推断会选择最后一个重载签名:
type Overloaded = (() => 'a') & (() => 'b') & (() => 'c')
type R = Overloaded extends () => infer R ? R : never
// R = 'c'(最后一个重载胜出)这是实现细节,但从 TypeScript 3.x 以来一直保持稳定。
联合坍缩行为
题目明确指出这些坍缩规则:
UnionToTuple<any | 'a'> // 等同于 UnionToTuple<any>
UnionToTuple<unknown | 'a'> // 等同于 UnionToTuple<unknown>
UnionToTuple<never | 'a'> // 等同于 UnionToTuple<'a'>
UnionToTuple<'a' | 'a' | 'a'> // 等同于 UnionToTuple<'a'>这些是 TypeScript 中基本的联合规则——any 和 unknown 会吸收其他成员,never 消失,重复项会被去重。
为什么这道题是"困难"级别
这道题需要组合三个高级概念:
- 逆变推断实现联合转交叉
- 重载解析行为获取"联合的最后一个"
- 递归元组构建,配合正确的
never检测
每个单独来看都不简单;将它们组合起来需要对 TypeScript 类型系统内部机制的深入理解。
核心要点
- 联合 → 交叉 → 重载函数 → 最后一个返回类型是从联合中逐个提取成员的处理流水线
[T] extends [never]是不触发分布的正确never检查方式- 函数重载推断选择最后一个重载的返回类型——这是很多高级类型的关键技巧
- 联合顺序是实现细节——不要依赖结果元组中元素的特定位置
- 这个模式是许多"联合转 X"类型挑战的基础构建块
