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;
// 拆成两层条件类型,效果相同(只是啰嗦一点)
最佳实践清单
元组题的约束统一
readonly any[]——一劳永逸兼容 as const写完必测空元组
<[]>——边界高频考点五虎将存成文件——明天递归题的乐高积木
每道题悬停中间态:先看
T extends [...]匹配出的 R 是什么再组装结果答题先默写一遍昨天的复盘模板
九、自测挑战
Q1:[infer F, ...any[]]、[any, ...infer R]、[...any[], infer L]、[...infer R, any] 分别捕获什么?空元组传入各走什么分支?
Q2:为什么 [1, 2, 3]["length"] 是 3 而 number[]["length"] 是 number?
Q3:[string, number][number] 的结果是什么?为什么?
Q4:手写 Shift<T>(去掉第一个元素返回剩余):Shift<[1, 2, 3]> → [2, 3]。
Q5:手写 Insert<T, V>(在头部插入,与 Unshift 有何不同?)。
Q6:keyof [string, number] 包含哪些成员?这对"映射元组"意味着什么(Day 19 伏笔)?
Q7:为什么元组题的泛型约束推荐写 readonly any[] 而不是 any[]?
Q8:TupleToObject<["a", "b"]> 用了哪两个机制的组合?
Q9:debounce 的类型签名里,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] 两个索引魔法,是很多"一行题"的答案。
延伸阅读
下一步
明天(Day 17)进入递归体操:递归三定律的系统化训练,主菜是 Includes(严格判等 + 递归扫描)和 Promise.all(元组同态映射的经典应用)——今天的五虎将将成为递归的积木。
元组是类型系统里"信息最完整"的容器——每个位置、每个长度都是已知数。体操爱元组,不是没有原因的。
每天花 2 小时,28 天通关 TypeScript 深入。第 3 周第 2 天,7 题到手!