01 类型推断、字面量与 as const

本系列面向当前 Node.js / TypeScript 项目,先解释开发中的真实报错,再给出处理选择。入口:目录。

1. 一段看似正常、却传不进去的代码

type Status = "pending" | "active" | "banned";

function setStatus(status: Status): void {
  console.log(status);
}

const statuses = ["pending", "active", "banned"];
for (const status of statuses) {
  setStatus(status); // 错误:string 不能赋给 Status
}

原因不是数组值写错了。TS 通常把 statuses 推断为 string[],循环里的 status 是 string,可能是任意字符串;setStatus 只接受三个特定值。

string:所有字符串都可能
Status:只允许 pending、active、banned

一个更大的集合不能直接当成更小的集合使用。TS 不会因为当前数组恰好只有三种值,就承诺它未来不被修改。

2. 为什么 const 没有解决问题?

const statuses = ["pending", "active", "banned"];
statuses.push("other"); // 合法,数组本身可修改

const 限制的是变量不能重新指向另一个数组,不是数组元素不能变化。

简单字符串变量则有区别:

const fixed_status = "active"; // 类型通常是 "active"
let editable_status = "active"; // 类型通常是 string

const config = { status: "active" };
config.status = "other"; // 普通可变属性通常扩大为 string

这叫字面量扩大(widening):TS 为未来可能的修改推断更宽的类型。它通常有帮助,只是偶尔与你想表达的固定配置不一致。

3. 方案 A:显式声明可修改的合法状态数组

type Status = "pending" | "active" | "banned";
const statuses: Status[] = ["pending", "active", "banned"];

statuses.push("active"); // 合法
statuses.push("other"); // 错误:不属于 Status

适合:数组需要增删,但元素始终必须属于某个已定义范围。

这不是固定三项的元组,而是任意长度的 Status 数组。当前开启 noUncheckedIndexedAccess,因此 statuses[0] 仍可能是 undefined;循环中的元素通常没有这个问题。

4. 方案 B:固定配置使用 as const

const statuses = ["pending", "active", "banned"] as const;

推断结果近似:

readonly ["pending", "active", "banned"]

拆开读:

const first_status = statuses[0]; // "pending",位置 0 确定存在
statuses.push("active"); // 错误:只读元组没有可用的 push

适合:状态集合、错误码配置、排序白名单等由开发者固定的值。下一章会从这个数组推导 Status,避免手写联合类型。

5. 数组与元组的区别,会影响第三方 API

const loose_order = [["last_name", "ASC"]];
// 通常是 string[][],不知道每个小数组必须有两项。

Sequelize 的 order 不是任意字符串二维数组,而有“字段 + 方向”等结构要求。推荐在构造位置表达契约:

import type { Order } from "sequelize";

const order: Order = [["last_name", "ASC"]];
order.push(["customer_id", "ASC"]);

不要认为所有 order 报错都应该加 as const。只读元组与库要求的可变数组可能不兼容:

const fixed_order = [["last_name", "ASC"]] as const;
// 在当前 v6 类型下,直接赋给 Order 可能因 readonly 不兼容而失败。

as const 精确表达“固定且只读”,不是万能类型修复。对需要继续 push 的 Order,显式类型标注更合适;也可根据场景使用 satisfies,见 03 章。

6. 接收只读输入,比要求调用者解除只读更好

如果函数只是读取数组,可以接受只读数组:

function joinStatuses(values: readonly string[]): string {
  return values.join(",");
}

const statuses = ["pending", "active", "banned"] as const;
joinStatuses(statuses); // 合法
joinStatuses(["active"]); // 普通数组也可传入

如果库确实需要一个可变数组,可复制外层数组:

function addStatus(values: string[]): void {
  values.push("active");
}

addStatus([...statuses]);

展开复制是运行时操作;as string[] 只是断言,没有复制。嵌套只读对象不会因为一次浅复制就全部变可变;二维 order 可以直接按 Order 重新构造,不必绕复杂断言。

readonly 输入也不是保证别人不会修改原数组。它限制当前函数通过这一引用能做什么。

7. includes 的烦恼:传入 string 为什么报错?

const statuses = ["pending", "active", "banned"] as const;

function check(value: string): boolean {
  return statuses.includes(value); // 错误:value 的范围比元素联合更宽
}

includes 的参数类型受数组元素类型约束。为了检查未知候选值,可在读取视图上适当扩大:

const statuses = ["pending", "active", "banned"] as const;
type Status = (typeof statuses)[number];

function isStatus(value: unknown): value is Status {
  const allowed: readonly string[] = statuses;
  return typeof value === "string" && allowed.includes(value);
}

原始配置仍保持精确类型;allowed 只是允许用任意 string 做查找的只读视图。函数体真实检查集合成员,类型谓词才有根据。

另一种简洁写法是 statuses.some((status) => status === value)。不要先把任意 value 断言为 Status,假装校验已经完成。

8. as const 不会冻结运行时对象

const tags = ["learning"];
const config = { status: "active", tags } as const;

config.status = "banned"; // TS 错误
config.tags.push("node"); // 合法:引用的 tags 本来就是可变数组

as const 在编译后消失,不会调用 Object.freeze。它会对直接写下的字面量保留更精确、只读的类型,但不等于递归冻结所有引用对象。

需要运行时冻结时另行考虑 Object.freeze,并注意它默认也是浅层冻结。日常后端固定配置一般先明确类型与修改边界,不必立即引入深冻结工具。

9. 选择速查

意图 起点
普通可修改字符串集合 推断为 string[] 即可
可修改,但只能包含合法状态 Status[]
固定配置,并希望保留每个具体值 as const
只读函数输入,允许普通数组或只读元组 readonly T[]
检查符合某契约,并保留有用推断 satisfies
库有明确结构要求,例如 Order 显式标注该库类型

10. 练习

  1. 观察 const 字符串、let 字符串、普通对象属性的推断差异。
  2. 给排序数组分别加 Order 标注和 as const,解释两种结果。
  3. 写一个只读取 film_ids 的函数,让它接受 readonly number[]。
  4. 用 isStatus 校验来自 query 的值,再调用只接受 Status 的函数。

下一章:typeof、keyof 与索引访问。