本节目标:读完这一节,你能在
T[]与Array<T>之间自如选择,知道什么时候该用readonly锁住数组;能说清元组与数组的本质差别,并用元组优雅地表达「固定结构的多返回值」;能读懂enum编译出的 JavaScript,并判断什么时候该改用as const对象。
3.2 数组、元组与枚举
上一节 3.1 原始类型与类型注解 处理的是「单个值」。真实程序里更多的是一组值:一列用户名、一组坐标、一份状态码清单。这一节就来讲这三种「复数形态」。
三者的定位可以用一句话概括:
| 类型 | 长度 | 元素类型 | 典型用途 |
|---|---|---|---|
| 数组 | 可变 | 全部相同 | 列表、集合、队列 |
| 元组 | 固定 | 按位置各不相同 | 多返回值、坐标、CSV 行 |
| 枚举 | 固定 | 一组命名常量 | 状态机、选项、错误码 |
数组的两种写法
TypeScript 为数组提供了两种完全等价的写法:
// 写法一:元素类型 + []
let names: string[] = ["ada", "linus"];
// 写法二:泛型 Array<T>
let ids: Array<number> = [1, 2, 3];
两者在类型上没有任何区别,选哪个是团队风格问题。实践中有一条被广泛接受的建议:
- 元素类型是简单类型时用
T[]:string[]、number[]、User[]。 - 元素类型本身很复杂时用
Array<T>:Array<string | number>比(string | number)[]好读,Array<() => void>比(() => void)[]清爽得多。
后者不是审美洁癖。看这个对比:
// 需要括号,容易漏
let mixed: (string | number)[] = ["a", 1];
// 无需括号,一眼看清
let mixed2: Array<string | number> = ["a", 1];
数组推断与空数组陷阱
数组字面量会被自动推断:
const scores = [90, 85, 77];
// 推断为 number[]
const rows = [
{ id: 1, name: "ada" },
{ id: 2, name: "linus" },
];
// 推断为 { id: number; name: string }[]
但空数组是例外——它没有元素可以推断,于是退化成 any[]:
const list = [];
list.push("hello");
list.push(42); // 不报错!list 是 any[]
正确做法是在声明处标注:
const list: string[] = [];
list.push(42);
// ❌ Argument of type 'number' is not assignable to parameter of type 'string'.
同样地,new Array(3) 也不能推断元素类型,要写 new Array<string>(3) 或者用 Array.from({ length: 3 }, () => "")。
只读数组
readonly T[] 与 ReadonlyArray<T> 会移除所有会修改数组的方法:
function sum(values: readonly number[]): number {
values.push(1);
// ❌ Property 'push' does not exist on type 'readonly number[]'.
return values.reduce((acc, n) => acc + n, 0);
}
注意几点:
readonly只约束数组本身。如果元素是对象,对象内部字段仍然可以改。readonly T[]与T[]是单向兼容的:T[]可以传给readonly T[](放宽),反过来不行。- 因此函数参数默认写
readonly T[]是个好习惯——调用方传普通数组也能通过,而函数内部无法偷偷修改。
对比一下三层的不可变性,容易混淆:
type ReadonlyNumbers = readonly number[]; // 不允许修改数组
type ReadonlyNumbers2 = ReadonlyArray<number>; // 等价写法
type ReadonlyNumbers3 = Readonly<number[]>; // 等价写法
多维数组
多维数组就是数组的数组,注解从右往左读:
const matrix: number[][] = [
[1, 2, 3],
[4, 5, 6],
];
const grid: string[][][] = [[["a"]]]; // 三维,少见但合法
// 每个子数组长度可以不同(锯齿数组)
const jagged: number[][] = [[1], [1, 2, 3]];
number[][] 读作「number 数组的数组」,即「一个数组,其元素是 number[]」。这个从内到外的读法在处理更复杂类型时同样适用。
元组:带位置的数组
元组(tuple)是长度固定、每个位置类型可以不同的数组。
let point: [number, number] = [10, 20];
let entry: [string, number] = ["age", 36];
它的价值在下面这个场景里体现得最充分:
// 用元组表达多返回值,比对象更轻
function divmod(a: number, b: number): [quotient: number, remainder: number] {
return [Math.floor(a / b), a % b];
}
const [q, r] = divmod(17, 5);
console.log(q, r); // 3 2
上面的 quotient: / remainder: 是具名元组元素(TS 4.0+),纯文档性质,不影响运行时,但能让编辑器的提示和错误信息可读性大幅提升。
元组的严格程度超出很多人预期:
let pair: [string, number] = ["a", 1];
pair[0] = 5;
// ❌ Type 'number' is not assignable to type 'string'.
pair = ["a", 1, 2];
// ❌ Source has 3 element(s) but target allows only 2.
注意最后一条:长度也参与类型检查。这是元组与数组最本质的差别。
可选元素与剩余元素
元组支持类似函数参数的可选与剩余语法:
// 可选元素:必须放在末尾
type Range = [start: number, end?: number];
const r1: Range = [0]; // ✅
const r2: Range = [0, 10]; // ✅
// 剩余元素
type Args = [string, ...number[]];
const a1: Args = ["sum"]; // ✅
const a2: Args = ["sum", 1, 2, 3]; // ✅
const a3: Args = [1, 2]; // ❌ 第一个必须是 string
有了剩余元素,就能给「参数转发」写出精确类型:
function logAndCall<T extends unknown[]>(
fn: (...args: T) => void,
...args: T
): void {
console.log("calling with", args);
fn(...args);
}
元组 vs 数组:怎么选
一个常见的困惑是「两个值的返回值,到底该用元组还是对象」。
| 维度 | 元组 | 对象 |
|---|---|---|
| 体积 | 无字段名开销 | 有字段名 |
| 可读性 | 依赖位置,需具名元素 | 天然自解释 |
| 解构 | const [a, b] = x | const { a, b } = x |
| 序列化 | 天然是数组,JSON 友好 | 字段名重复占带宽 |
| 扩展性 | 加字段会破坏调用方 | 加可选字段较平滑 |
经验法则:内部、短生命周期、位置语义明显的场景用元组([x, y]、[error, result]、Object.entries 的返回值);跨模块、跨网络、需要演进的场景用对象。
顺带一提,Object.entries 返回的正是元组数组:
const dict = { a: 1, b: 2 };
for (const [key, value] of Object.entries(dict)) {
// key: string, value: number
console.log(key, value);
}
枚举:为什么它在现代 TypeScript 里不讨喜
enum 是 TypeScript 独有、JavaScript 里不存在的语法。它最基础的用法:
enum OrderStatus {
Pending, // 0
Paid, // 1
Shipped, // 2
Cancelled, // 3
}
console.log(OrderStatus.Paid); // 1
数字枚举会自动从 0 递增,也可以手动指定起点:
enum HttpStatus {
OK = 200,
Created = 201,
BadRequest = 400,
NotFound = 404,
}
字符串枚举更常见,因为调试时值本身可读:
enum Role {
Admin = "ADMIN",
Editor = "EDITOR",
Viewer = "VIEWER",
}
enum 的编译产物
理解 enum 的关键是看它编译成什么。数字枚举会生成一个 IIFE,并附带反向映射:
// enum OrderStatus { Pending, Paid } 编译后
var OrderStatus;
(function (OrderStatus) {
OrderStatus[(OrderStatus["Pending"] = 0)] = "Pending";
OrderStatus[(OrderStatus["Paid"] = 1)] = "Paid";
})(OrderStatus || (OrderStatus = {}));
所以下面两个方向都能取到值:
OrderStatus.Paid; // 1
OrderStatus[1]; // "Paid" —— 反向映射,字符串枚举没有
这带来三个实际后果:
- enum 不是「零成本抽象」,它会往产物里塞进一段运行时代码。
- 数字枚举不安全:任何
number都能赋给它。
const s: OrderStatus = 99; // ✅ 不报错!这是真实缺陷
const enum曾被推荐用来消除开销,但它在isolatedModules(Vite、esbuild、Babel 等单文件转译场景)下会报错,跨包发布更是重灾区。
const enum Flag { A = 1 }
// 在 isolatedModules 下:
// ❌ Cannot access ambient const enums when 'isolatedModules' is enabled.
现代替代:as const 对象 + 联合类型
社区如今的主流做法是用「as const 对象 + 联合类型」替代枚举:
export const OrderStatus = {
Pending: "PENDING",
Paid: "PAID",
Shipped: "SHIPPED",
Cancelled: "CANCELLED",
} as const;
// 从值反推出联合类型
export type OrderStatus = (typeof OrderStatus)[keyof typeof OrderStatus];
// 等价于 "PENDING" | "PAID" | "SHIPPED" | "CANCELLED"
这个方案同时拿到了四样东西:
- 零运行时开销:
as const完全擦除,产物里就是一个普通对象字面量。 - 值可迭代:
Object.values(OrderStatus)直接能用,enum 做不到。 - 类型精确:
OrderStatus类型是字面量联合,传入"PENDING "(带空格)会立刻报错。 - 与 ESM 天然兼容:没有
isolatedModules的坑。
代价是写法稍长,且需要自己写一行 (typeof X)[keyof typeof X] 的推导。关于 as const 与 keyof/typeof 的机制,9.1 keyof·typeof 与索引访问类型
会给出完整解释;如果你现在就想深挖类型层面的组合技巧,可以延伸阅读站内专题 TypeScript 类型编程
。
常见报错速查
| 报错信息 | 含义 | 修法 |
|---|---|---|
Type 'number' is not assignable to type 'string' | 数组元素类型不匹配 | 改值或改元素类型 |
Source has 3 element(s) but target allows only 2 | 元组长度不符 | 补齐或改用数组 |
Property 'push' does not exist on type 'readonly number[]' | 对只读数组做了写操作 | 去掉 readonly 或复制一份 |
A tuple type element list cannot be empty after a rest element | 剩余元素后还有别的元素 | 把剩余元素放最后 |
Cannot access ambient const enums when 'isolatedModules' is enabled | const enum 与单文件转译冲突 | 改用 as const 对象 |
小结
这一节把三种「复数形态」讲完了:
- 数组用
T[]或Array<T>,两者等价;复杂元素类型优先用Array<T>免去括号。空数组必须显式标注,否则退化成any[]。 readonly T[]只锁数组本身,不锁元素内部;函数参数默认写成只读是好习惯。- 元组 = 固定长度 + 按位置定类型,适合多返回值;具名元素、可选元素、剩余元素让它的表达力接近函数签名。
- 枚举会生成运行时代码、数字枚举不类型安全、
const enum与isolatedModules冲突,现代项目更推荐as const对象 + 联合类型。
到这里,类型系统里「正常」的部分我们已经过了一遍。但真实代码里总有些值是我们暂时说不清或者根本不该有的——接口返回的原始 JSON、永远不会返回的函数、没有返回值的回调。下一节 3.3 any·unknown·never·void 与类型断言
就专门处理这些边界情况,并讲清 as、!、satisfies 三个最容易被滥用的运算符。
阅读导航:上一节:3.1 原始类型与类型注解 · 下一节:3.3 any·unknown·never·void 与类型断言 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。