在TypeScript类型体操中,经常需要从一个元组类型里提取所有元素,并生成一个由这些元素类型组成的联合类型。比如定义 type EventNames = ['click','focus','blur'] 后,想得到 'click' | 'focus' | 'blur'。这个操作看似简单,但如果你把元组直接当联合类型使用,或者用 keyof 去取,结果会变成数组索引和方法的联合,而不是元素类型的联合。本文会从索引访问出发,逐步拆解 T[number] 的完整机制,并延伸到只读元组、条件类型推断和复杂场景。

首先要理解元组和联合类型在结构上的差异。元组是长度固定、按位置排列的数组类型,例如 type Tuple = [string, number, boolean] 表示第0位必须是string,第1位必须是number,第2位必须是boolean。联合类型表示一个值可以是多个类型中的一个,例如 string | number | boolean。二者虽然都可以表达多个类型,但元组保留位置信息和顺序,联合类型不保留位置信息,只保留成员集合。因此直接赋值 let name: EventNames = 'click' 行不通,因为 EventNames 是数组类型而不是元素联合。要完成转换,核心是找到一种方式访问元组中每一个索引位置上的类型,再把这些类型合并。
索引访问类型与 T[number] 的简洁写法
TypeScript提供索引访问类型,写法为 T[K],其中 K 是键名或键的联合。对数组和元组来说,数字索引可以访问元素类型。例如 Tuple[0] 是 string,Tuple[1] 是 number。如果传入一个数字字面量的联合,比如 0 | 1 | 2,TypeScript会计算 Tuple[0 | 1 | 2],结果就是 string | number | boolean。问题是你通常不知道元组的长度,所以需要一个能代表所有数字索引的类型,这就是 number。
在数组或元组类型中,number 作为索引可以遍历所有数字键,因此 Tuple[number] 会直接得到所有元素类型组成的联合类型。对于 type EventNames = ['click','focus','blur'],EventNames[number] 的结果就是 'click' | 'focus' | 'blur'。这是最简洁的解法,不需要递归或条件类型。示例:
type EventNames = ['click', 'focus', 'blur']; type EventName = EventNames[number]; // EventName = 'click' | 'focus' | 'blur' const name: EventName = 'click'; // 合法 const invalid: EventName = 'hover'; // 报错
为什么 number 能代表所有数字索引?TypeScript中数组类型有一个数字索引签名,返回元素类型的联合。元组虽然长度固定,但同样兼容数字索引签名,因此 T[number] 可以工作。这个写法对于字符串字面量元组特别有用,可以很方便地生成字符串字面量联合类型。
用条件类型与 infer 手动实现转换
除了 T[number],还可以用条件类型和 infer 自己实现一个 TupleToUnion 工具类型。这种写法在类型体操中更通用,也能帮助你理解类型系统如何递归处理元组。思路是:如果 T 是一个数组或元组,用 infer U 推断出元素类型,然后返回 U;否则返回 never。最简单写法是 type TupleToUnion<T> = T extends (infer U)[] ? U : never。
这种写法的关键是 (infer U)[] 会匹配任意数组类型,并推断出元素类型 U。对于元组 [string, number],TypeScript会将 U 推断为 string | number。示例:
type TupleToUnion<T> = T extends (infer U)[] ? U : never; type T1 = TupleToUnion<[string, number, boolean]>; // T1 = string | number | boolean type T2 = TupleToUnion<[]>; // T2 = never
如果只处理元组,不希望普通数组也被展开,可以约束 T extends readonly unknown[],并且使用 infer U 获取整个元组元素联合:
type StrictTupleToUnion<T extends readonly unknown[]> = T[number]; // 或者 type StrictTupleToUnion2<T extends readonly unknown[]> = T extends readonly (infer U)[] ? U : never;
这里 readonly unknown[] 约束可以同时接受只读和普通元组。使用 unknown 作为元素类型不会丢失原始信息,因为推断发生在实际类型上,而不是约束上。
只读元组、嵌套元组与 never 处理
只读元组使用 as const 或 ReadonlyArray 声明,例如 const arr = ['click', 'focus'] as const。它的类型是 readonly ['click', 'focus'],同样支持 T[number],所以 typeof arr[number] 可以得到联合类型。不过要注意普通 Array<string> 与 ReadonlyArray<string> 在函数参数中不兼容,因此工具类型最好加上 readonly 约束。
嵌套元组需要明确目标。如果元组元素本身也是元组,例如 [[string, number], [boolean]],使用 T[number] 得到的是 [string, number] | [boolean],并不会穿透到内层。如果需要提取所有叶子类型,需要递归处理:
type DeepTupleToUnion<T> =
T extends readonly (infer U)[]
? U extends readonly unknown[]
? DeepTupleToUnion<U>
: U
: never;
type Nested = [[string, number], [boolean]];
type Leaves = DeepTupleToUnion<Nested>;
// Leaves = string | number | boolean
当元组中出现 never 时,T[number] 不会特别处理 never,联合类型会自动忽略它,因此得到的结果可能比预期少一个成员。例如 [string, never, number][number] 结果是 string | number。这是联合类型的天然行为,并不算错误。如果你需要显式保留 never,可以在结果中加 never,但这通常没有实际意义。
实现细节与常见误区
一个常见误区是用 keyof T 获取元素类型。对元组 [string, number],keyof T 会得到 number | '0' | '1' | 'length' | 'push' | ...,再通过 T[K] 得到数组方法和元素类型的混合,无法得到纯元素联合。原因在于 keyof 针对对象或数组的所有键,而元组转型的重点是数字索引,因此 number 索引访问是更精确的选择。
另一个误区是在条件类型中把 T extends (infer U)[] ? U : never 写在不带 readonly 的约束下,结果只读元组无法匹配。比如 type T = readonly ['click', 'focus'],如果约束是 unknown[],读取 T[number] 没问题,但条件类型 T extends (infer U)[] 会失败,因为 readonly 数组不能赋值给可变数组。加 readonly 后即可。
性能方面,T[number] 比递归条件类型更高效,因为它是 TypeScript 内置的索引访问操作,不需要编译器展开递归。对于大多数场景,优先使用 T[number]。只有需要对每个元素做额外判断或递归展开时才引入自定义条件类型。事件名称、枚举键、路由字符串等场景基本都能用一行工具类型完成。
最终可以封装一个通用工具类型:
type ElementOf<T extends readonly unknown[]> = T[number]; type Names = ElementOf<['click', 'focus', 'blur']>; // Names = 'click' | 'focus' | 'blur' type Mixed = ElementOf<[string, number, boolean]>; // Mixed = string | number | boolean
这样在项目中调用 ElementOf 就能直观表达意图,也便于后续调整只读兼容逻辑。
TypeScript类型体操元组转联合类型条件类型推断修改时间:2026-09-27 19:20:44