TypeScript 里的数组概念和 JavaScript 基本一致,但多了类型约束后,写法和选择反而容易让人犹豫。常见问题包括:Array<number> 和 number[] 到底有没有区别;为什么空数组有时被推断成 never[];想让数组只读时应该用 readonly 还是 ReadonlyArray<T>;固定长度、固定顺序的数据是不是应该用元组。这篇文章不打算只罗列 API,而是从类型推演和实战避坑两个角度,把 TypeScript 数组的声明方式、操作特征、元组选择以及常见误区一次讲清楚。

一、数组类型声明:number[]、Array 泛型与只读数组怎么选
在 TypeScript 中声明一个数字数组有两种等价写法:number[] 和 Array<number>。前者是后者的简写语法,编译结果和类型检查行为没有区别。团队协作时建议统一一种,一般业务代码里 number[] 更简短,涉及嵌套或函数类型时 Array<...> 的可读性更好。例如 Array<() => void> 比 (() => void)[] 更容易辨认边界,但这类差异并不是硬性规则。
只读数组则要区分 readonly number[] 和 ReadonlyArray<number>。readonly 修饰的是数组或元组类型本身,而 ReadonlyArray 是内置泛型接口。两者在使用上基本等价,都会让 push、pop、sort、splice 等原地修改方法不可用。但要注意,readonly number[] 作为参数时虽然可以接受普通 number[],反过来把只读数组传给参数类型为 number[] 的函数会报错。
// 两种普通数组写法
const scores1: number[] = [90, 85, 92];
const scores2: Array<number> = [90, 85, 92];
// 只读数组
const readonlyScores1: readonly number[] = [88, 91];
const readonlyScores2: ReadonlyArray<number> = [88, 91];
// 只读数组不允许原地修改
// readonlyScores1.push(99); // 类型报错
// 只读数组可以放心传给 readonly number[] 参数
function total(scores: readonly number[]): number {
return scores.reduce((sum, item) => sum + item, 0);
}
total(readonlyScores1);
total(scores1);
更推荐把函数入参写成 readonly T[],因为它能同时兼容可变数组和只读数组,并且明确表达函数不会修改原数组。返回类型则不要随意声明成只读数组,否则调用方后续想原地修改会受阻。普通业务状态、需要频繁增删的元素列表才使用可变数组;配置项、函数参数、模块间传递的集合,优先考虑只读约束。
二、常见操作如何影响类型:push、map、filter、find 与解构
数组初始化时如果没有显式标注,TypeScript 会根据元素推断。写成 const list = [] 会得到 never[],这是因为空数组无法提供元素类型信息。此时如果后续 list.push(1),编辑器会提示 number 不能赋给 never。正确做法是提前标注:const list: number[] = []。同理,const data = [1, 'a'] 会被推断为 (string | number)[],这通常符合预期,但如果希望限制为 number[],需要显式声明。
push 会检查入参与数组元素类型是否兼容,pop 返回 number | undefined,因为空数组时 pop() 的结果是 undefined。map 会根据回调返回值推断新数组类型,例如对 number[] 执行 map(item => item.toString()) 得到 string[]。filter 默认只返回原数组类型,即使过滤逻辑能判断元素更具体的类型,普通回调也不会自动收窄,必须使用类型谓词 (x): x is string 才能把 (string | number)[] 过滤成 string[]。find 的返回值是 T | undefined,因此拿到结果后需要判空或使用可选链。
const list: number[] = [];
list.push(1);
list.push(2);
// list.push('3'); // 报错
const last = list.pop(); // number | undefined
if (last !== undefined) {
console.log(last.toFixed(2));
}
const strings = list.map((item) => item.toString()); // string[]
const mixed: (string | number)[] = ['a', 1, 'b', 2];
const onlyStrings = mixed.filter((x): x is string => typeof x === 'string');
// onlyStrings 的类型会被收窄为 string[]
const target = list.find((item) => item > 1); // number | undefined
console.log(target?.toFixed(2));
解构和剩余元素也需要留意边界。普通数组解构时,TypeScript 只会根据索引给出元素类型,不会自动提示越界风险。比如 const [first] = numbers 得到 number,但若数组为空,运行时 first 是 undefined。开启 noUncheckedIndexedAccess 后,索引访问 numbers[0] 会变为 number | undefined,迫使开发者处理空值。剩余元素 const [head, ...tail] = numbers 中,tail 仍为 number[]。这些看似细节的差异,在大型项目里能显著减少运行时错误。
三、元组、as const 与只读元组:固定长度和顺序时怎么选
当数据长度固定、每个位置含义不同时,普通数组就不够精确。比如坐标点 [x, y] 需要两个数字,用 number[] 无法防止写成三个元素,也无法保证每个位置都是数字。此时应该使用元组类型 [number, number]。元组的赋值会检查长度和顺序,const point: [number, number] = [1, 2] 合法,而 [1, 2, 3] 或 [1, '2'] 会报错。访问时 point[0] 得到 number,若尝试访问 point[2],TypeScript 会直接提示越界错误,这比普通数组更安全。
as const 会让数组字面量变成只读元组,并且保留字面量类型。例如 const methods = ['GET', 'POST'] as const 得到的类型是 readonly ['GET', 'POST'],而不是 string[]。这种写法非常适合定义路由方法、状态枚举、表格列配置等不希望被修改的常量集合。由于元素类型被收窄为 'GET' | 'POST',后续使用 methods.includes(someString) 时,someString 必须是该联合类型的成员,否则会报错,从而提前暴露传参错误。
// 元组:固定长度和顺序
const point: [number, number] = [3, 7];
// const badPoint: [number, number] = [3, 7, 9]; // 长度错误
const x = point[0]; // number
// const outOfRange = point[2]; // 元组越界报错
// as const:只读字面量元组
const httpMethods = ['GET', 'POST'] as const;
// httpMethods.push('DELETE'); // 报错
// 具名元组与剩余元素
type ApiResult = [status: number, payload: string, ...rest: string[]];
const result: ApiResult = [200, 'ok', 'cache', 'no-store'];
元组还支持可选元素和剩余元素,例如 [string, number?] 表示第二个数字可有可无,[string, ...number[]] 表示至少一个字符串后跟任意个数字。具名元组 [id: number, name: string] 不会改变类型行为,但能在编辑器中显示更清晰的提示。实际项目中,函数需要返回多个不同含义的值时,用元组比返回对象更轻量;而 React 的 useState 返回 [state, setState] 就是典型元组应用。不过一旦数据结构开始膨胀、元素超过三四个,或未来可能增加字段,就应该改用接口或对象,而不是继续堆叠元组。
四、常见误区与避坑建议
第一个高频误区是空数组不标注类型。前面提到 const list = [] 推断为 never[],如果项目里有大量动态数组,建议从一开始就写清楚元素类型。第二个误区是忽略索引访问可能越界。默认配置下 numbers[10] 的类型会被当成 number,但运行时可能是 undefined。开启 noUncheckedIndexedAccess 可以让索引访问结果带上 undefined,但开启后所有数组索引访问都需要判空或多写守卫,适合对空值安全要求较高的项目。可以在 tsconfig.json 中统一配置。
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true
}
}
const nums = [1, 2, 3];
const first = nums[0]; // number | undefined
if (first !== undefined) {
console.log(first + 1);
}
第三个误区是 any[] 污染。从 JSON.parse 或某些网络请求返回值得到的 any,一旦赋给数组,后续访问元素、调用方法都会跳过类型检查,错误被推迟到运行时。更稳妥的做法是先用 unknown 接收,再用接口和类型守卫收窄。例如定义 interface User { id: number; name: string },然后通过 Array.isArray 判断和字段校验,把 unknown 转换为 User[]。即使不写完整运行时校验,至少为接口返回数据声明类型,而不是直接使用 any[]。
interface User {
id: number;
name: string;
}
function normalizeUsers(input: unknown): User[] {
if (!Array.isArray(input)) {
return [];
}
return input.filter((item): item is User => {
return typeof item === 'object' && item !== null && 'id' in item && 'name' in item;
});
}
第四个误区是只读数组被误传。把 as const 生成的只读元组直接传给 function sort(items: string[]) 会报错,因为只读数组不能赋给可变数组参数。如果排序函数内部确实不会修改原数组,应该把参数改为 readonly string[],并在返回时复制一份。类似地,Object.freeze 只影响运行时,TypeScript 类型上并不会自动变成只读,需要结合 as const 或 Readonly 工具类型。总体原则是:同构、长度不定的集合用数组;固定长度、位置含义不同的用元组;不需要调用方修改的加只读;从不可信数据源拿到的数组务必做运行时校验,而不是交给 any 一路放行。这样能减少相当一部分隐藏的类型漏洞。
TypeScript数组数组类型元组修改时间:2026-09-25 16:41:44