Yuzhe's Blog

yuzhes

联合类型转元组 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

这是最巧妙的部分。让我们逐步追踪:

  1. U extends any ? () => U : never 将联合分布到函数类型中:

    • A | B | C(() => A) | (() => B) | (() => C)
  2. UnionToIntersection<...> 将其转换为函数交叉:

    • (() => A) & (() => B) & (() => C)
  3. 不同返回类型的函数交叉表现为重载函数。当你从返回类型 infer R 时,TypeScript 选择最后一个重载的返回类型。

  4. 所以 R = C(或 TypeScript 认为联合中”最后”的那个成员)。

注意:TypeScript 处理联合成员的顺序是实现细节,规范中没有保证。但它足够一致,使得这个方案能正常工作。

第三步 — 递归构建元组

type UnionToTuple<U, Last = LastOfUnion<U>> =
  [U] extends [never]
    ? []
    : [...UnionToTuple<Exclude<U, Last>>, 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 中基本的联合规则——anyunknown 会吸收其他成员,never 消失,重复项会被去重。

为什么这道题是”困难”级别

这道题需要组合三个高级概念:

  1. 逆变推断实现联合转交叉
  2. 重载解析行为获取”联合的最后一个”
  3. 递归元组构建,配合正确的 never 检测

每个单独来看都不简单;将它们组合起来需要对 TypeScript 类型系统内部机制的深入理解。

核心要点