类型体操(一):元组翻转与长度

本节目标

1. 为什么需要类型体操?

先说清楚动机。假设你在写一个通用工具 get,它从数组里按索引取值:

function get<T extends any[]>(arr: T, i: number) {
  return arr[i]
}

arr[i] 的返回类型在 TS 里只能推断成 T[number](所有元素类型的联合)。但如果 T 是 [string, number],你取第 0 个明明是 string、取第 1 个明明是 number——「按位置精确取值」这件事,普通写法做不到,必须在类型层面根据索引算出结果类型。这就是类型体操的用武之地:它让你在编译期「算」出更精准的类型。

一句话:类型体操 = 把原本要在运行时用代码做的运算,搬到类型层面提前算好,让编译器帮你查错。

2. 取元组长度:Length<T>

// 运行环境:Node.js 16+ + typescript;类型检查 npx tsc --noEmit ts-l16.ts

// 元组的 length 是一个具体的数字字面量类型
type Length<T extends readonly any[]> = T['length']

type L1 = Length<[1, 2, 3]>        // 3
type L2 = Length<[]>               // 0

关键点:T['length'] 之所以能拿到字面量 3,是因为元组(tuple)记录了每个位置的类型和总长度,而普通数组 number[] 的 length 只是 number。试一下:

type A = [string, number]['length'] // 2(字面量)
type B = string[]                  // 普通数组没有固定长度

所以 Length 只在元组上有意义——这也提醒我们:类型体操很多能力都依赖「元组」这个既有固定长度、又能被 [infer Head, ...infer Tail] 拆解的结构。

3. 翻转元组:Reverse<T>(重点)

先放一个「新手常见错误版本」,看看它为什么不对:

// 辅助:取最后一个
type Last<T extends readonly any[]> =
  T extends [...any[], infer L] ? L : never

// ❌ 错误示范:试图一次性「把最后一个搬到最前」
type ReverseBad<T extends readonly any[]> =
  T extends [infer First, ...infer Rest]
    ? [Last<Rest>, First]   // 只搬一次,剩余部分 Rest 没继续处理!
    : []

这个版本只对长度为 2 的元组「看起来对」,长度更长就丢数据。正确思路是递归头插:每次把「头」拿出来,放到「已翻转的尾巴」结果的前面。

// ✅ 正确版:每次把 Head 放到「Reverse(Tail) 的结果」后面
type Reverse<T extends readonly any[]> =
  T extends [infer Head, ...infer Tail]
    ? [...Reverse<Tail>, Head]
    : []

type R1 = Reverse<[1, 2, 3]> // [3, 2, 1]
type R2 = Reverse<[]>        // []

手推一遍 Reverse<[1, 2, 3]> 的展开过程(这是最重要的能力,一定要自己推):

Reverse<[1, 2, 3]>
= [...Reverse<[2, 3]>, 1]            // Head=1, Tail=[2,3]
= [...[...Reverse<[3]>, 2], 1]       // Head=2, Tail=[3]
= [...[...[...Reverse<[]>, 3], 2], 1] // Head=3, Tail=[]
= [...[...[...[], 3], 2], 1]         // Reverse<[]> = []
= [...[...[3], 2], 1]
= [...[3, 2], 1]
= [3, 2, 1]

你发现没有:这就是类型层面的「循环」——靠 T extends [infer Head, ...infer Tail] 不断「剥头」,把 Tail 越剥越短,直到 T 变成 [](递归基线,返回空元组)。每一层都把刚刚剥下的 Head 追加到结果末尾。

4. 顺手攒一个「元组工具箱」

搞懂了递归套路,下面几个都是同一种思维,抄一遍就熟:

// 取第一个
type First<T extends readonly any[]> =
  T extends [infer H, ...any[]] ? H : never

// 取最后一个
type Last<T extends readonly any[]> =
  T extends [...any[], infer L] ? L : never

// 头部追加(类似 Array.unshift)
type Unshift<T extends readonly any[], V> = [V, ...T]

// 尾部追加(类似 Array.push)
type Push<T extends readonly any[], V> = [...T, V]

// 拼接两个元组(类似 Array.concat)
type Concat<A extends readonly any[], B extends readonly any[]> =
  [...A, ...B]

// 弹出最后一个(返回去掉末位的元组)
type Pop<T extends readonly any[]> =
  T extends [...infer Rest, any] ? Rest : []

// 把元组「摊平」成联合类型:[string, number] -> string | number
type TupleToUnion<T extends readonly any[]> =
  T extends [infer H, ...infer R] ? H | TupleToUnion<R> : never

其中 TupleToUnion 很妙:它利用了分布式条件类型——H | TupleToUnion<R> 在递归展开时把每个元素「或」起来。你会在很多库(比如把 keyof 的结果做成联合)里见到这种写法。

5. 两个高频坑

名词解释

类型体操(Type Gymnastics):在「类型层面」写出的递归/条件运算,比如翻转元组、提取字段。它不改变运行时行为,只在编译期推导出更精准的类型。像健身一样练思维,所以叫「体操」。著名题库 type-challenges 就是一堆这类题目,分 easy/medium/hard。

infer:条件类型里的「捕获关键字」。T extends [infer Head, ...infer Tail] 意思是「如果 T 能拆成『一个头 + 剩余尾巴』,就把头和尾巴的类型分别捕获到 Head、Tail 里」。它让类型能「拆包取值」,是类型递归的核心工具。

元组(Tuple) vs 数组(Array):元组是「长度固定、每个位置类型可不同」的数组(如 [string, number]),数组是「长度任意、元素同类型」(如 number[])。类型体操大量依赖元组的「固定长度 + 可拆解」特性。

课后练习

练习 1:写 First<T> 取元组第一个元素。

答案:

type First<T extends readonly any[]> = T extends [infer H, ...any[]] ? H : never

练习 2:Reverse<[]> 是什么?为什么?

答案:[]。递归基线:T 不匹配 [Head, ...Tail](即空了),返回 [],正确结束,不会无限递归。

练习 3:写 Concat<[1, 2], [3, 4]> 让它得到 [1, 2, 3, 4]。

答案:

type Concat<A extends readonly any[], B extends readonly any[]> = [...A, ...B]
type C = Concat<[1, 2], [3, 4]> // [1, 2, 3, 4]

练习 4:TupleToUnion<[1, 'a', true]> 的结果是什么?

答案:1 | 'a' | true(三个元素类型被「或」在一起)。提示:回顾分布式条件类型的展开。

总结

我的态度很实在:类型体操是「思维训练」不是「日常必用」。99% 的业务代码用不到 Reverse<T>,但练它的价值在于——当你哪天要写一个通用工具类型(比如「把后端蛇形字段转成前端驼峰」),你脑子里已经有「递归 + infer + 条件分发」这套肌肉记忆,不至于卡死。我建议把它当「智力操」每周刷两题(type-challenges 的 easy 起步),但绝不在业务代码里炫技:能用一个 Pick/Map 解决的问题,绝不手搓二十行递归类型。可读性永远优先于聪明。