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"]
拆开读:
readonly:通过这个类型视图不能修改元素或 push。[...]:元组,位置和长度已知。- 每项是具体字符串类型,不是统一扩大为 string。
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. 练习
- 观察 const 字符串、let 字符串、普通对象属性的推断差异。
- 给排序数组分别加 Order 标注和 as const,解释两种结果。
- 写一个只读取 film_ids 的函数,让它接受 readonly number[]。
- 用 isStatus 校验来自 query 的值,再调用只接受 Status 的函数。
下一章:typeof、keyof 与索引访问。