《TypeScript编程入门》3.2 数组、元组与枚举

本节讲透 TypeScript 里三种「复数形态」的类型:数组、元组与枚举。先对比 T[] 与 Array<T> 两种数组写法、只读数组与多维数组,再讲元组如何用固定长度与固定位置表达「结构化的多返回值」,包括具名元素、可选元素与剩余元素。最后剖析 enum 的编译产物、反向映射与常量枚举的陷阱,并给出用 as const 对象加联合类型替代枚举的现代写法,附可运行的编译前后对照。

本节目标:读完这一节,你能在 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] = xconst { 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" —— 反向映射,字符串枚举没有

这带来三个实际后果:

  1. enum 不是「零成本抽象」,它会往产物里塞进一段运行时代码。
  2. 数字枚举不安全:任何 number 都能赋给它。
const s: OrderStatus = 99; // ✅ 不报错!这是真实缺陷
  1. 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 enabledconst 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 与类型断言 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes