【TS】day16-tuple-gymnastics

作者:mario 发布时间: 2026-08-31 阅读量:6 评论数:0

TypeScript 元组体操 — 变长元组与推断位置,类型的数组操作大全

昨天立好了规矩(三板斧 + 判题基建),今天开始第一项专项训练:元组。核心新知识只有两个——变长元组(Variadic Tuple)和推断位置(infer 在展开位置)——却能让元组拥有数组的全部操作:取头、取尾、拼接、插入、删除。一口气拿下 7 道 easy,组建你的"元组五虎将"。


目录


一、元组基础回顾与冷知识

1.1 元组是什么(10 秒回顾)

// 元组 = 长度固定、每个位置类型可不同的数组
type Point = [x: number, y: number];

// 第 1 周你用过它:函数的剩余参数类型(第 6 天 FirstArg 的产出)
type Args = [id: string, temp: number];

// 元组的字面量化(as const):
const t = [1, "a", true] as const;
// t 的类型:readonly [1, "a", true] —— 每个元素都是字面量类型 + 只读

1.2 三个改变认知的冷知识(今天的地基)

// 冷知识 1:元组的 "length" 是精确的数字字面量类型!
type L = [string, number, boolean]["length"];
// L = 3 —— 不是 number!类型系统"知道"元组有几个元素
//(普通数组的 length 才是 number)

// 冷知识 2:T[number] 取"所有元素的联合"
type U = [string, number, boolean][number];
// U = string | number | boolean
// 索引签名用 number = "取任意数字下标" = 元组所有成员的联合

// 冷知识 3:keyof 元组 = 下标字符串 + 数组方法名
type K = keyof [string, number];
// "0" | "1" | "length" | "toString" | "pop" | "push" | ...
//(下标是字符串形式的数字!这是 Day 19"映射元组"的伏笔)

这三个冷知识就是今天 7 道题里 3 道的"一行答案"——类型体操一半靠机制,一半靠"知道类型系统知道什么"。


二、变长元组:TS 4.0 的礼物

2.1 元组里的展开语法

// 已知(值层面):数组的展开拼接
const combined = [...arr1, ...arr2];

// TS 4.0 起(类型层面):元组也支持展开拼接!
type Combined = [...[string, number], ...[boolean]];
// [string, number, boolean] —— 类型的"接龙"

// 展开位置还可以放"不定长"的部分:
type WithRest = [first: string, ...rest: number[]];
//   固定 1 个 string 打头 + 任意个 number 收尾
type A = WithRest;          // ["a"] 合法、["a", 1, 2] 合法

2.2 变长元组的两大用途

// 用途 1:拼接(今天的 Concat/Push/Unshift 题)
type Concat<T extends readonly unknown[], U extends readonly unknown[]> =
  [...T, ...U];

// 用途 2:类型安全的"可变参数"签名(第 2 周已悄悄用过)
function useConfigs<T extends readonly Config[]>(
  ...configs: T            // ⭐ 每个 config 的类型都被保留在元组 T 里
): MergeConfigs<T> { /* ... */ }

useConfigs(
  { theme: "dark" },       // T[0] 的类型被精确推断
  { retry: 3 }             // T[1] 的类型被精确推断
);

三、推断位置:头尾拆分术

3.1 infer 与展开的组合句式

// 核心句式(今天最重要的 4 行代码):

type Head<T extends readonly any[]> = T extends [infer F, ...any[]] ? F : never;
//                                                  ↑
//                            F 捕获第一个元素,...any[] 吞掉剩余(不关心)

type Tail<T extends readonly any[]> = T extends [any, ...infer R] ? R : never;
//                                                  ↑
//                            any 占掉第一个,R 捕获剩余全部

type Last<T extends readonly any[]> = T extends [...any[], infer L] ? L : never;
//                                              ↑
//                     展开放前面:任意前缀被吞掉,L 捕获最后一个

type Init<T extends readonly any[]> = T extends [...infer R, any] ? R : never;
//                                              ↑
//                     R 捕获除最后一个外的全部

3.2 位置语法的记忆法

[infer F, ...any[]]    → infer 在【头】,any 吞尾 → "取头"
[any, ...infer R]      → any 吞头,infer 在【尾】 → "取尾(剩全部)"
[...any[], infer L]    → 展开在前,infer 在【末位】 → "取最后一个"
[...infer R, any]      → infer 在【前段】,any 吞末位 → "取除最后外的全部"

口诀:infer 捕获它"站的那个位置",any/...any[] 是"不关心"的占位。

3.3 边界行为:空元组

type H = Head<[]>;    // never([] 不匹配 [infer F, ...any[]] → else 分支)
type T = Tail<[]>;    // never 或 [](取决于 else 分支写什么)

// ⚠ 题目用例常测空元组——写完必查:<[]> 走哪个分支?

四、七道题逐个击破

每道题先自己写 15 分钟,再看答案。答案后附"拆解思路"。

4.1 第 14 题 · First<T>(easy)

// 题目:First<[3, 2, 1]> → 3;First<[]> → never
// 用例:
//   Expect<Equal<First<[3, 2, 1]>, 3>>
//   Expect<Equal<First<[() => 123, { a: string }]>, () => 123>>
//   Expect<Equal<First<[]>, never>>
//   Expect<Equal<First<[undefined]>, undefined>>

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

拆解:第一问(形状)元组→单元素;第二问(拆法)头尾拆分取头;第三问(递归)不需要——空元组走 else 返回 never,天然终止。

4.2 第 15 题 · Last<T>(medium,有变长元组后只是小弯)

// 题目:Last<[1, 2, 3]> → 3;Last<[]> → never

type Last<T extends readonly any[]> = T extends [...any[], infer L] ? L : never;

拆解:和 First 完全同构,只是 infer 挪到末位。注意:还有一个"歪解"值得一看——

// 歪解(同样能过):尾递归版
type Last2<T extends readonly any[]> =
  T extends [...infer R, infer L] ? (R extends [] ? L : Last2<R>) : never;
// 每轮剥掉最后一个,剩 1 个时返回——性能更差,但展示了"递归扫描元组"的思路

4.3 第 16 题 · Pop<T>(medium)

// 题目:Pop<[1, 2, 3]> → [1, 2];Pop<[]> → []

type Pop<T extends readonly any[]> = T extends [...infer R, any] ? R : [];

拆解:弹出最后一个 = 返回"除最后一个外的全部"= Init 五虎将。注意 else 分支返回 [](空元组弹完还是空)。

4.4 第 533 题 · Concat(easy)

// 题目:Concat<[1], [2, 3]> → [1, 2, 3]

type Concat<T extends readonly unknown[], U extends readonly unknown[]> =
  [...T, ...U];

拆解:变长元组的"接龙"直接一行。注意as const 的 readonly 元组作为输入也能匹配([...T] 展开自动转可变)。

4.5 第 3057 题 · Push(easy)

// 题目:Push<[1, 2], 3> → [1, 2, 3]

type Push<T extends readonly unknown[], V> = [...T, V];

4.6 第 3060 题 · Unshift(easy)

// 题目:Unshift<[1, 2], 0> → [0, 1, 2]

type Unshift<T extends readonly unknown[], V> = [V, ...T];

4.7 第 18 题 · Length<T>(easy)

// 题目:Length<["a", "b"]> → 2;Length<[]> → 0

type Length<T extends readonly any[]> = T["length"];

拆解:第一节的冷知识 1——元组的 "length" 是数字字面量类型。注意:约束必须是 readonly any[](用例里有 as const 的元组)。

4.8 加餐 · 第 11 题 TupleToObject(easy)

// 题目:TupleToObject<["a", "b"]> → { a: "a"; b: "b" }

type TupleToObject<T extends readonly (string | number | symbol)[]> = {
  [P in T[number]]: P;
};

拆解:两个机制的组合——T[number] 先转联合(冷知识 2),映射类型再"联合 → 对象"。这是元组题与对象题的桥梁


五、元组五虎将工具箱

把今天的产出沉淀成一个文件(明天递归体操直接复用):

// ===== tuple-utils.ts:元组五虎将 =====

/** 取头元素类型 */
export type Head<T extends readonly any[]> =
  T extends [infer F, ...any[]] ? F : never;

/** 取尾(除第一个外的所有元素)*/
export type Tail<T extends readonly any[]> =
  T extends [any, ...infer R] ? R : [];

/** 取最后一个元素类型 */
export type Last<T extends readonly any[]> =
  T extends [...any[], infer L] ? L : never;

/** 取除最后一个外的所有元素 */
export type Init<T extends readonly any[]> =
  T extends [...infer R, any] ? R : [];

/** 元组转联合(附加虎:用的频率最高) */
export type Union<T extends readonly any[]> = T[number];

六、工业实战场景

6.1 场景一:类型安全的流水线校验

/**
 * 大屏数据处理流水线:raw → 解析 → 反序列化 → 归一化 → 设备列表
 * 每一步的输出类型必须能"喂"给下一步——编译期校验链路连续性
 */
type Steps = [
  (raw: unknown) => string,
  (json: string) => RawPayload,
  (payload: RawPayload) => Device[]
];

/** 递归校验:每步输出是下步输入的父类型(或相同) */
type IsContinuous<T extends readonly ((...args: any) => any)[]> =
  T extends [
    infer F extends (...args: any) => infer FOut,
    ...infer Rest extends ((...args: any) => any)[]
  ]
    ? Rest[0] extends (arg: FOut) => any
      ? IsContinuous<Rest>
      : false
    : true;

type Ok = IsContinuous<Steps>;   // true
// 把后两步换个位置 → false,且具体哪一环断了在类型提示里可见 ⭐
//(infer extends 语法是 TS 4.8 的新句式,顺便学掉)

6.2 场景二:表格列定义的字面量约束

/**
 * 大屏表格组件:列配置的 key 字段必须是数据源的真实字段
 */
interface DeviceRow {
  id: string;
  name: string;
  temp: number;
  status: string;
}

type ColumnDef<Row> = {
  key: keyof Row & string;    // ⭐ 字段名直接约束
  title: string;
}[];

const columns: ColumnDef<DeviceRow> = [
  { key: "id", title: "编号" },
  { key: "temp", title: "温度" }
  // { key: "tmep", title: "温度" }   // ❌ 拼错编译期拦截
];

/** 用元组体操进一步:列顺序也被记录(支持"按列取值"类型安全) */
type ColumnKeys<C extends readonly ColumnDef<any>[]> =
  C extends readonly [infer F extends ColumnDef<any>, ...infer R extends ColumnDef<any>[]]
    ? F["key"] | ColumnKeys<R>
    : never;

type Keys = ColumnKeys<typeof columns>;   // "id" | "temp"

6.3 场景三:防抖节流的参数透传(第 16 周旧友的类型版)

/**
 * debounce 的类型难点:包装后的函数要保留原函数的参数类型
 */
function debounce<F extends (...args: any[]) => any>(
  fn: F,
  delay: number
): (...args: Parameters<F>) => ReturnType<F> {
  let timer: ReturnType<typeof setTimeout>;
  return function (this: unknown, ...args: Parameters<F>) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  } as any;
  // ⚠ 类型体操的边界:实现里的 as any 可以接受——
  // 类型体操的价值在"对外的类型签名精确",内部实现另说
}

// 使用:参数类型完整透传
const search = debounce((keyword: string, page: number) => {
  return fetch(`/api?q=${keyword}&p=${page}`);
}, 300);
// search(1, 2)   ❌ 参数类型被完整保留——编译期拦截
search("CNC", 1);   // ✅

七、类比记忆:剥洋葱 vs 接龙

推断位置 = 剥洋葱
┌────────────────────────────────────┐
│  洋葱(元组)一层一层剥              │
│  [infer F, ...any[]] 剥最外层一片    │
│  [...any[], infer L] 从底部掏一片    │
│  每剥一次,"剩下的"是新的洋葱        │
│  剥到空 → 终止(else 分支)          │
└────────────────────────────────────┘

变长元组 = 接龙
┌────────────────────────────────────┐
│  [...T, ...U] 两队人马接成一条       │
│  [...T, V]   队尾加一人             │
│  [V, ...T]   队头插一人             │
│  值的展开语法直接搬到类型层          │
└────────────────────────────────────┘

八、常见坑点与最佳实践

坑点 1:忘了 readonly 兼容

// 题目用例经常传 as const 的元组:
const tuple = [1, 2] as const;   // readonly [1, 2]

// 你的实现若约束成 any[]:
type Bad<T extends any[]> = T extends [infer F, ...any[]] ? F : never;
type X = Bad<typeof tuple>;
// ❌ 编译错误:readonly [1,2] 不匹配 any[](readonly 不能赋给 mutable)

// ✅ 约束统一用 readonly any[](可变元组反而能赋给它)
type Good<T extends readonly any[]> = T extends [infer F, ...any[]] ? F : never;

坑点 2:else 分支随手写 never

// Pop<[]> 期望返回 [](空元组),但你顺手写了 never:
type PopBad<T extends readonly any[]> = T extends [...infer R, any] ? R : never;
type X = PopBad<[]>;   // never ⚠ —— 用例期望 []

// 教训:else 分支也是逻辑的一部分——先看用例再写分支

坑点 3:以为 T[“length”] 对普通数组也精确

type L1 = [1, 2, 3]["length"];   // 3 ✅ 元组:精确
type L2 = number[]["length"];    // number ⚠ 普通数组:长度未知

// 只有"长度固定"的元组才有字面量 length
//(这反过来说明:体操题的输入几乎都是元组/字面量,普通数组没信息可挖)

坑点 4:infer extends 新语法的版本要求

// infer F extends (...args: any) => infer FOut 是 TS 4.8 的语法
// 老版本项目里会报错——降级写法:
type Fallback<T> = T extends [infer F, ...any[]]
  ? F extends (...args: any[]) => infer FOut ? FOut : never
  : never;
// 拆成两层条件类型,效果相同(只是啰嗦一点)

最佳实践清单

  1. 元组题的约束统一 readonly any[]——一劳永逸兼容 as const

  2. 写完必测空元组 <[]>——边界高频考点

  3. 五虎将存成文件——明天递归题的乐高积木

  4. 每道题悬停中间态:先看 T extends [...] 匹配出的 R 是什么再组装结果

  5. 答题先默写一遍昨天的复盘模板


九、自测挑战

Q1[infer F, ...any[]][any, ...infer R][...any[], infer L][...infer R, any] 分别捕获什么?空元组传入各走什么分支?

Q2:为什么 [1, 2, 3]["length"]3number[]["length"]number

Q3[string, number][number] 的结果是什么?为什么?

Q4:手写 Shift<T>(去掉第一个元素返回剩余):Shift<[1, 2, 3]>[2, 3]

Q5:手写 Insert<T, V>(在头部插入,与 Unshift 有何不同?)。

Q6keyof [string, number] 包含哪些成员?这对"映射元组"意味着什么(Day 19 伏笔)?

Q7:为什么元组题的泛型约束推荐写 readonly any[] 而不是 any[]

Q8TupleToObject<["a", "b"]> 用了哪两个机制的组合?

Q9debounce 的类型签名里,Parameters<F>ReturnType<F> 分别解决什么问题?为什么内部实现用了 as any 却仍算好类型?

Q10:不看文档默写"五虎将"。


十、总结与知识图谱

元组体操(Day 16)
│
├── 冷知识地基
│   ├── T["length"]:元组长度是数字字面量类型
│   ├── T[number]:取全部元素的联合
│   └── keyof 元组:下标字符串 + 数组方法名
│
├── 变长元组(TS 4.0)
│   ├── 展开拼接:[...T, ...U] / [...T, V] / [V, ...T]
│   └── 用途:类型安全可变参数((...configs: T))
│
├── 推断位置(头尾拆分术)
│   ├── [infer F, ...any[]] 取头
│   ├── [any, ...infer R] 取尾
│   ├── [...any[], infer L] 取末元素
│   └── [...infer R, any] 去末元素
│
├── 七题战绩
│   ├── First(14)/ Last(15)/ Pop(16)
│   ├── Concat(533)/ Push(3057)/ Unshift(3060)
│   ├── Length(18)/ TupleToObject(11 加餐)
│   └── 桥梁题:T[number] + 映射 = 元组转对象
│
├── 五虎将工具箱(明日弹药)
│   Head / Tail / Last / Init / Union
│
└── 工业落地
    ├── 流水线连续性编译期校验
    ├── 表格列 key 字面量约束
    └── debounce 参数透传(Parameters/ReturnType)

一句话总结:变长元组给类型层带来了"展开接龙",推断位置带来了"头尾剥皮"——两招组合,元组的增删截取全部类型化;而 T["length"]/T[number] 两个索引魔法,是很多"一行题"的答案。


延伸阅读

资源

说明

Variadic Tuple Types - TS 4.0

变长元组官方说明

infer extends - TS 4.8

推断位置的新语法

type-challenges 相关题号:14/15/16/18/11/533/3057/3060

今日全部题目


下一步

明天(Day 17)进入递归体操:递归三定律的系统化训练,主菜是 Includes(严格判等 + 递归扫描)和 Promise.all(元组同态映射的经典应用)——今天的五虎将将成为递归的积木。


元组是类型系统里"信息最完整"的容器——每个位置、每个长度都是已知数。体操爱元组,不是没有原因的。

每天花 2 小时,28 天通关 TypeScript 深入。第 3 周第 2 天,7 题到手!


评论