可变性 — let/const、readonly 与 as const 如何改写推断¶
这一章讲一件事: 你声明变量的方式,在悄悄改变 TypeScript 眼中的类型。 let 还是 const、属性可不可写、加不加 as const——每一个选择都是推断的输入。 读懂这一章,「TypeScript 为什么不懂我」类报错的半壁江山,你就都能自己诊断了。
1. 顶层全景:可变性声明 → 推断宽窄的映射表¶
声明方式 推断结果(以值 "rock" 为例)
─────────────────────────────────────────────────────
let x = "rock" string(宽:以后可能被重赋值)
const x = "rock" "rock"(窄:字面量类型)
{status: "on-sale"} {status: string}(属性可变,照样宽)
{status: "on-sale"} as const {readonly status: "on-sale"}(深度窄)
图说:越「锁得住」,推断越「窄」。锁的粒度有四个层级:变量 → 属性 → 数组 → 深度(整棵树)。
读这张表有一个关键动作:每一行都要能用一句「为什么」解释—— let 宽是因为重赋值合法;const 窄是因为绑定不可换;对象属性宽是因为属性可改; as const 窄是因为整个结构被声明为常量。四句话就是本章的全部内容, 表格只是它们的速查形式。反过来说,任何时候你看到一个推断「不合预期」, 回到这张表找它属于哪一行,那一行的「为什么」就是答案。
主线例子按这张表走四站:let、对象属性、readonly、as const。
2. 第一站:let 被「拓宽」¶
type AlbumGenre = "rock" | "country" | "electronic";
let albumGenre = "rock";
const handleGenre = (genre: AlbumGenre) => { /* ... */ };
handleGenre(albumGenre); // ❌ Argument of type 'string' is not assignable to parameter of type 'AlbumGenre'.
奇怪吗?"rock" 明明在 AlbumGenre 并集里。悬停 albumGenre,答案揭晓:
它被推断成了 string,不是字面量 "rock"。
原因原书讲得很白:let 声明的变量以后可以被重新赋值,所以 TypeScript 把它拓宽(widen)到宽类型——
现在装 "rock",天晓得三行以后装什么1。
拓宽是为了让「以后的赋值」不报错:推断成 string,是给未来的重赋值留足空间。
解法还是第 04 章那一套——给变量标注:
变量仍可重赋值,但只能在这个并集内换值;传给 handleGenre 也不再报错2。
拓宽的边界:什么不拓宽¶
「let 会被拓宽」有一条重要边界,原书在报错文本里露了一半,值得说透: 已经被收窄成字面量并集的变量,不因为 let 再被拓宽成 string。
type AlbumGenre = "rock" | "country" | "electronic";
let albumGenre: AlbumGenre = "rock";
albumGenre = "country"; // ✅ 在并集内换值,合法
albumGenre = "jazz"; // ❌ 不在并集内
标注之后,变量的类型是 AlbumGenre(一个有限的集合),不是 string——
标注把「未来的改写空间」从「任意字符串」收缩成了「这三个值」,
拓宽发生在没有标注、从值推断的那条路径上;一旦类型被显式钉住,
let 只表示「可以换值」,不再表示「可以拓宽」2。
这条边界解释了为什么「let + 标注」是应用里最常见的组合: let 负责「可变」,标注负责「可变范围的合同」。两个职责各管一段, 互不越界——这也预告了第 12 章「变量赢」规则:同场时,标注的说了算。
3. 第二站:const 保持字面量,但对象属性例外¶
把 let 换成 const:
const albumGenre = "rock";
handleGenre(albumGenre); // ✅
// 悬停 → const albumGenre: "rock"
albumGenre = "country"; // ❌ Cannot assign to 'albumGenre' because it is a constant.
变量再也不能被重赋值,推断就无需留空间——直接收成最窄的字面量类型 "rock",恰好是
AlbumGenre 的子集,窄进宽行,畅通无阻3。
原书指出这背后的设计取向:TypeScript 在借用 JavaScript 的语义做推断—— const 在 JavaScript 里本来就禁止重赋值,TypeScript 只是把这条运行时的规矩, 翻译成了类型层面的「收窄红利」4。这也顺便给了个实用推力:能写 const 就写 const,类型更严,还有白拿的字面量推断5。
这个「翻译 JavaScript 语义」的取向值得多看一眼,因为它是本章大部分规则的总源头:
JavaScript 的规定 TypeScript 的翻译
────────────────────────── ────────────────────────────
let 允许重赋值 → 推断要留宽(string 而非 "rock")
const 禁止重赋值 → 推断可以收最窄(字面量)
对象属性总是可写 → const 对象的属性照样拓宽
(语言没有属性级常量) → readonly 是类型层的发明,JS 里没有对应物
图说:左列是 JavaScript 自己承诺的「可写性」,右列是编译器据此给出的
「类型宽度」——每一行都是同一条翻译规则的实例。
由此得到一个跨章的观察:TypeScript 的推断不是凭空的设计品味, 而是对 JavaScript 语义的一致性延伸。这条观察会在第 13 章再次显形 (那里会见到它的若干「怪癖」,大多也是同一条翻译规则的产物)。
对象属性的反直觉:const 锁不住属性¶
主线例子进入第二幕。还是唱片状态:
type AlbumAttributes = {
status: "new-release" | "on-sale" | "staff-pick";
};
const albumAttributes = {
status: "on-sale",
};
updateStatus(albumAttributes); // ❌ {status: string} 不是 AlbumAttributes
albumAttributes 明明也是 const!为什么 status 还是被推断成 string?
因为 const 只锁变量绑定,不锁对象内容。 JavaScript 里这完全合法:
对象在 JavaScript 里天生可变,属性的值随时可能被改写——所以 TypeScript 照样把 status
拓宽成 string,和 let 同款逻辑6。报错信息拆得很细:
Type 'string' is not assignable to type '"new-release" | "on-sale" | "staff-pick"'——
字面量并集没拓宽,拓宽只发生在「可能被重新赋值的东西」上7。
三种解法,各教一件事:
- 调用处内联对象——
updateStatus({status: "on-sale"}):内联的对象没有「先声明、后被改」的机会, TypeScript 直接收窄推断8; - 变量标注——
const albumAttributes: AlbumAttributes = {...}:效果和 let 那站一样, 属性仍可改,但只能在合法值里改(albumAttributes.status = "new-release"通过)9; - 第三站、第四站的 readonly / as const——治本,下面走。
练习 7-1 是同一枚硬币的数组版:对象数组 buttonsToChange 的 type 属性被推断成 string[],
同样因为数组可变——buttonsToChange[0].type = "something strange" 完全合法。
解法同上两式(标注数组类型,或调用处内联)10。
4. 第三站:readonly——类型层面的锁¶
解决什么问题: 标注的解法让「改值合法但受约束」,可有些对象你根本不想让它被改。 readonly 修饰符钉住单个属性:
interface Album {
readonly title: string;
readonly artist: string;
status?: "new-release" | "on-sale" | "staff-pick";
genre?: string[];
}
创建之后,title、artist 不许动;可选的 status、genre 照旧可改11。
想整体锁?用工具类型 Readonly<T> 包住:所有属性一次性只读12。
const readOnlyWhiteAlbum: Readonly<Album> = { /* ... */ };
readOnlyWhiteAlbum.genre = ["rock", "pop"]; // ❌ Cannot assign to 'genre'
// because it is a read-only property.
两个必须知道的边界:
- 只作用在类型层面。 运行时的对象照旧可变——readonly 是编译器的护栏,不是运行时的墙13。 (这和第 03 章「薄语法」原则完全一致:类型被擦除,readonly 也随之被擦除。)
- 只有一层深。 Readonly 不会递归——嵌套对象的内部属性不受保护14。
数组版:只读数组与可变数组的单向门¶
数组的锁有两把钥匙,效果相同:readonly string[] 和 ReadonlyArray<string>15。
锁住之后,会改内容的办法(push、pop)整个从类型上消失,
不改内容的办法(map、reduce)照常工作——因为它们返回新数组16:
const readOnlyGenres: readonly string[] = ["rock", "pop"];
readOnlyGenres.push("experimental"); // ❌ Property 'push' does not exist on type 'readonly string[]'.
const upper = readOnlyGenres.map((g) => g.toUpperCase()); // ✅
只读与可变之间有一条单向通行规则,值得单独记牢:
可变数组可以进只读参数的槽位;只读数组进不了可变参数的槽位。
const mutableGenres = ["rock", "pop"];
printGenresReadOnly(mutableGenres); // ✅ 我只承诺不碰它,传可变的进来没问题
printGenresMutable(mutableGenres); // ✅
const readOnlyGenres: readonly string[] = ["rock", "pop"];
printGenresReadOnly(readOnlyGenres); // ✅
printGenresMutable(readOnlyGenres); // ❌ 只读数组不能交给可能 push 的函数
逻辑就一句话:声明 readonly 参数的函数保证不改数组,所以传什么都安全;
声明可变参数的函数可能改,传个只读数组进来就是把别人的锁撬了17。
原书给这条规则的评语很形象:readonly 会「传染」——一个数组标了只读,
下游处理它的每个函数都得跟着声明只读;但这个传染是有收益的:
它保证数组在整条调用链上不被改18。
为什么只读数组的 map 还能用:方法列表随类型变¶
「push 消失、map 保留」的机制值得点破:这不是特殊白名单,是方法的返回类型决定的。
map、filter、slice 这些方法的返回类型是普通数组(新数组),
不修改 this,所以在 readonly string[] 上依然合法;
push、pop、splice 的返回类型涉及修改 this 本身,类型系统让它们从
只读数组的成员列表里整个消失——方法的可用性跟着类型的可变性走,
这是结构化类型的自然推论,不是编译器的补丁16。
一个容易顺手犯的验证错误:在只读数组上跑了 sort() 以为没事——
sort 是「原地排序」,返回 this,它在只读数组上同样不存在。
判别口诀:这个方法返回「一个新的」还是「改我自己」? 返回新数组的都安全。练习 7-2 给的就是一个标准应用场景:
参数本来只是「打印名字」,没有任何理由改它——那就标 readonly string[],
顺便让 @ts-expect-error 断言「push 应该报错」真正兑现19。
练习 7-3 是传染规则的反向利用:坐标元组 Coordinate 标成 readonly [number, number],
那个会 pop 两次的危险函数就再也接不住它了——「函数要可变数组,而你是只读元组」,红线20。
原书建议:元组能标 readonly 就标,防的就是这种「形参是数组、实参是元组」的误伤21。
一个诚实的已知缺陷:readonly 对象属性的洞¶
数组的规则很严,对象却有个口子。把带只读属性的对象传给一个会改它的函数——不报错:
type ReadonlyAlbum = { readonly genre: string };
type MutableAlbum = { genre: string };
const readonlyAlbum: ReadonlyAlbum = { genre: "Jazz Rap" };
const processAlbum = (album: MutableAlbum) => {
album.genre = "Pop Punk"; // 改!
};
processAlbum(readonlyAlbum); // ❗不报错——只读属性被偷偷改了
按数组的规则这该报错,但 TypeScript 早年有意放行:真去强制,工程上太痛苦 (类型层面很难追踪一个只读对象有没有被哪个角落的可变别名改掉)。 原书的态度很坦白:这是 known limitation,「强制执行会更正确,但没有执行」, 近年社区对这个行为的审查越来越多,未来版本可能收紧22。 实用结论:对 readonly 对象属性留个心眼——它们偶尔是可变的; 数组的只读规则才是可依赖的23。
这个洞的利用与防御¶
知道这个洞的存在,实用上要问两问。第一问:我有没有在依赖这个洞? 如果代码里存在「把只读对象传给可变函数、并指望它不改」的写法, 那不是「聪明地绕过了限制」,是搭在漏洞上的桥——将来 TypeScript 收紧 (原书明说「may be changed」22)时,这些代码会集体报错。 第二问:我要不要主动防它? 防法不在类型层(洞是类型层自己的), 而在约定层:团队约定「会改对象的函数,参数一律不标 readonly 对象, 而是收可变对象」——让「想改」和「能改」在签名上对齐,洞就绕开了。
还有一种更积极的用法:利用洞做「宽进严出」——函数内部把对象当可变的处理 (排序、填充),对外却暴露只读类型。这是合法且常见的性能技巧, 前提是函数真的会把它恢复成调用者期待的状态;洞在这里不是缺陷, 是你自己签的契约,只是这份契约没有编译器背书,全靠注释和测试。
5. 第四站:as const——深度不可变换字面量推断¶
解决什么问题: 第二站的标注解法有个副作用:它把属性的推断整个换成了声明类型。
const albumAttributes: AlbumAttributes = { status: "on-sale" };
// 悬停 → { status: "new-release" | "on-sale" | "staff-pick" }
属性不再是精确的 "on-sale",而是整个并集——标注赢了,字面量丢了24。
能不能既锁死、又保留字面量推断?能:as const。
as const 是断言:「这整个对象,连同它所有的嵌套属性,都是常量」。
回报是双份的:每个属性都挂上 readonly,且推断精确到字面量25。
再改属性,报错;大型配置对象不想被改、又想要精确字面量类型——as const 就是为这个场景生的26。
照例提醒:只作用类型层面,运行时对象依旧可变27。
两个必须知道的相互作用¶
① as const vs 变量注解:变量赢。
const albumAttributes: AlbumAttributes = { status: "on-sale" } as const;
albumAttributes.status = "new-release"; // ✅ 不报错!属性竟是可变的
注解和断言同场,像两股力掰手腕——变量注解赢。变量接管了值,as const 施加在值上的只读性
被整个覆盖。原书给的评语是「Not fun」:你以为上了双保险,实际注解把保险拆了28。
这套「变量 vs 值」的相互作用,第 12 章会整个讲透;这里先记规则:
as const 要生效,就别在同一条声明上放变量注解。
② as const vs Object.freeze:类型锁 vs 运行时锁。
JavaScript 自己的不可变方案是 Object.freeze。对照悬停:
const shelfLocations = Object.freeze({ entrance: { status: "on-sale" }, /* ... */ });
// 悬停 → Readonly<{ entrance: { status: string } }>
// 只锁第一层!嵌套的 status 照旧可变:shelfLocations.entrance.status = "new-release" ✅
const shelfLocations = { /* 同上的对象 */ } as const;
// 悬停 → { readonly entrance: { readonly status: "on-sale" } }
// 每一层都锁:连嵌套属性也是字面量类型
三重差别:Object.freeze 是运行时冻结(只一层),as const 是类型层面冻结(深度);
冻结层次不同带来推断精度不同(freeze 的嵌套属性还是 string,as const 的是 "on-sale");
运行时成本也不同——freeze 要真的执行冻结动作,as const 编译后什么都不剩29。
原书的结论:除非你真的需要运行时冻结顶层,否则一律用 as const30。
把「两个世界」的差别再坐实一层,可以看各自的失效方式——这往往是选择工具时 最该问的问题:
as const 的失效:在纯 JavaScript 里被改。类型锁只在编译期存在,
擦除之后运行时没有任何东西拦着——但只要项目全链路过 tsc,违约方会当场报错。
Object.freeze 的失效:在严格模式之外被静默忽略(非严格模式赋值不报错);
深层的嵌套对象从来就没被锁过,改它一声不吭。
图说:两个锁各有一个「防不住」的方向——一个防不住运行时,
一个防不住嵌套。要双保险就两个都用:as const 拿深度和推断,freeze 拿运行时。
(双保险的成本:freeze 的浅层限制对 as const 是冗余的——多数项目只取 as const。)
最后补一个精度细节:freeze 的悬停显示 Readonly<{...}>——它借助的是第 07 章
的 Readonly 工具类型,而 Readonly 同样只锁一层。所以「freeze 只锁一层」
在类型层面和运行时层面是同一个「只一层」,不是巧合,是同一套浅层语义的两次实现29。
数组上的三种 as const 粒度(练习 7-4)¶
回到按钮数组,as const 给三种粒度,对应三种推断:
// 粒度 1:整个数组 as const → 只读元组,函数参数也得跟着只读
const buttonsToChange = [{type: "button"}, {type: "submit"}] as const;
// 悬停 → readonly [{readonly type: "button"}, {readonly type: "submit"}]
modifyButtons(buttonsToChange); // ❌ 只读数组进不了可变参数
// 解法:modifyButtons 的参数改成 readonly ButtonAttributes[]
// 粒度 2:每个对象成员 as const → 数组本身仍可 push,元素只读且字面量
[{type: "button"} as const, {type: "submit"} as const];
// 悬停 → ({readonly type: "button"} | {readonly type: "submit"})[]
// 粒度 3:只给字符串字面量 as const → 没有 readonly,但字面量保住了
[{type: "button" as const}, {type: "submit" as const}];
// 悬停 → ({type: "button"} | {type: "submit"})[]
粒度 3 最实用:想要字面量推断,但不想锁任何东西——数组可 push、属性可改, 只是类型从此精确31。粒度 1 则是「传染规则」的完整演示:你锁得越狠, 下游签名就得跟着改32。
四站决策:遇到「推断太宽」的报错怎么选¶
把全章折成一张流程卡,日常报错对着走:
报错:「'string' is not assignable to type 'xxx 字面量并集'」
│
├─ 这个值需要被重新赋值吗?
│ ├─ 需要 → 变量标注成那个并集类型(let 站的解法)
│ └─ 不需要 → 改用 const + 下列之一:
│ ├─ 只是临时传给函数 → 调用处内联对象
│ ├─ 想要精确字面量、不介意全锁 → as const
│ ├─ 想要字面量、不想锁 → 值级 as const(粒度 3)
│ └─ 只想防某几个属性被改 → readonly / Readonly<T>
│
└─ 数组进不了可变参数?
└─ 检查是不是只读传染:改参数为 readonly,而不是去掉对象的锁。
图说:从轻到重——内联 < 标注 < readonly < as const;锁越重,传染越广。
判断「哪一级合适」的经验锚点:这个对象的写者有几个。 只有创建者自己读 → 内联/const 就够;会被传给很多人 → readonly 参数(只读传染是从 「谁可能写」推导出来的契约);是发布出去就不变的配置 → as const。 把「可变性」当成 API 契约的一部分来设计,而不是事后再补的锁——这是本章真正的收束。
补一个决策树没显式列出的第五种情况:「这个值来自函数返回值」。 函数返回的对象是「陈旧对象」(第 13 章的概念),推断跟随 return 处的字面量或标注: 想要字面量,就在 return 处用 as const 或标返回类型;想让调用方自由扩展, 就什么不加——返回值是推断链条里最容易被忽略的一段,出问题先悬停 return。
6. 四个练习:把四站各走一遍实战¶
原书本章四道练习题,恰好一道驻扎一个站。逐个走查,每道只取它独有的那一课。
练习 7-1:数组版的「对象属性照样拓宽」¶
modifyButtons 收 ButtonAttributes[],ButtonAttributes 的类型属性是
"button" | "submit" | "reset" 的字面量并集。传入的数组看起来完全合法,
红线却画在 buttonsToChange 上10。
排查的第一步照旧是悬停:
// 悬停 buttonsToChange →
const buttonsToChange: {
type: string; // ← 不是 "button" | "submit" | "reset",是 string!
}[];
原因正是第 3 站的结论,数组版:数组可变,所以元素的属性照常被拓宽——
buttonsToChange[0].type = "something strange" 完全合法,
编译器必须给这种未来的改写留空间34。
两个修法,各对应一种「让收窄发生」的姿势:
// 修法一:给数组变量标注——声明处接管,属性仍是字面量并集
const buttonsToChange: ButtonAttributes[] = [ /* ... */ ];
// 修法二:调用处内联——没有「先存变量后被改」的机会,直接收窄推断
modifyButtons([{ type: "button" }, { type: "submit" }]);
两个都有效,选择的依据是「这个数组之后还会不会被单独改」: 会(后续还要 push、改元素)→ 修法一;只是这一次性传给函数 → 修法二35。 这道题的诊断链值得背下来:悬停看到 string 而不是字面量并集, 问一句「这东西可变吗」,答案就是拓宽的出处。
练习 7-2:只读数组的「双向锁」实验¶
printNames(names: string[]) 里有两行被 @ts-expect-error 标了
「不应该允许」的代码:names.push("John") 和 names[0] = "Billy"。
但参数标成 string[] 时,这两行本来就不报错——@ts-expect-error 自己红了
(「预期落空」)36。任务:改参数类型,让这两行真的被拦。
解法两条,等价:
function printNames(names: readonly string[]) { /* ... */ } // ① readonly 前缀
function printNames(names: ReadonlyArray<string>) { /* ... */ } // ② 工具类型
有趣的在悬停:不管用哪种写法,参数都显示为 readonly string[]——
两种语法是同一类型的两个名字,编译器内部统一成一种表示37。
锁上之后,push 和下标赋值双双报错,@ts-expect-error 兑现;
而 for...of 只读遍历照旧工作——这正是第 4 节「改内容的办法消失、
不改内容的办法保留」的实验版证明。
练习 7-3:只读元组挡住「形参是数组、实参是元组」¶
一个 dangerousFunction(arrayOfNumbers: number[]),函数体里 pop() 两次;
一个元组 const myHouse: Coordinate = [0, 0](Coordinate 是 [number, number])。
把元组传进去,运行时会 pop 掉它的两个元素——但类型系统不拦,
@ts-expect-error 再次落空38。
修法只有一行,但发生在类型上而不是函数上(题目要求函数不动):
锁上之后,同一个调用报错——这次是真报错:
Argument of type 'Coordinate' is not assignable to parameter of type 'number[]'.
The type 'Coordinate' is 'readonly' and cannot be assigned to
the mutable type 'number[]'.
报错文本就是第 4 节单向通行规则的原文:可变参数「可能改」,只读元组不许被改, 所以此路不通39。原书给的推广建议很直白:元组能标 readonly 就标—— 元组的意义就是「恰好这两个位置的两个值」,任何会改长度的函数都不该接得住它40。 这道题是整个「只读传染」章节里回报最直接的一题:一行 readonly, 换来一个真实的运行时事故在编译期现形。
练习 7-4:as const 的三级粒度(本章综合题)¶
练习 7-4 是 7-1 的重考,但加了一个限制:不许改 ButtonAttributes 和 modifyButtons,
只许动 buttonsToChange 数组本身,而且要「推断出字面量类型」。
三个解法,恰好是 as const 粒度的三级光谱:
选项一:整个数组 as const。
const buttonsToChange = [{ type: "button" }, { type: "submit" }] as const;
// 悬停 → readonly [{readonly type: "button";}, {readonly type: "submit";}]
字面量推断成功,但换来一个新报错:readonly [...] 不能赋给 ButtonAttributes[]——
整组 as const 把数组变成了只读元组,而 modifyButtons 的参数是可变数组。
只读传染的第 ① 课:你锁得越狠,下游签名越要跟着改(题目不许改函数,
所以这个选项在题面下其实走不通,除非函数参数本来就是 readonly)41。
选项二:每个对象成员 as const。
const buttonsToChange = [{ type: "button" } as const, { type: "submit" } as const];
// 悬停 → ({readonly type: "button";} | {readonly type: "submit";})[]
数组本身不再只读——push 照旧可用——但每个元素的属性锁死了,字面量也保住了。
这是「要字面量、不要锁数组」的中间档41。
选项三:只给字符串字面量 as const。
const buttonsToChange = [{ type: "button" as const }, { type: "submit" as const }];
// 悬停 → ({type: "button";} | {type: "submit";})[]
连 readonly 都没有了:属性可改、数组可 push,只是类型从此精确。 原书给这个选项的评语是「想要允许改写、又想要字面量推断时,偶尔有用」41。
三个选项排成光谱,本章的全部机制就在里面了:
粒度 1(整组) → 字面量 + 深锁 + 只读传染(下游得跟)
粒度 2(成员) → 字面量 + 元素锁 + 数组可动
粒度 3(字面量) → 字面量 + 什么都不锁
什么都不加 → string(拓宽,回到 7-1 的报错)
图说:从左到右,锁越来越轻、传染越来越小、推断保持字面量。
选哪档,取决于「这东西以后谁会写」——第 5 节决策树的实战版。
四道练习合成一张「报错 → 处方」卡¶
四道练习的诊断入口其实是同一个报错:'string' is not assignable to type '"button" | "submit" | "reset"'
(或它的变体)。把这个报错和它的四种处方并排,本章就收成了一张随身卡:
报错:「string 放不进字面量并集」
│
├─ 来源是 let 变量? → 标注成那个并集(7-1 之前的修法)
├─ 来源是对象/数组属性? → 标注集合类型,或调用处内联(7-1)
├─ 来源是函数想防别人改? → 参数改 readonly(7-2)
├─ 来源是元组怕被当数组? → 元组标 readonly(7-3)
└─ 想要字面量但不想受锁? → as const 三档选一(7-4)
图说:同一个报错,五个出处,五张处方。先悬停确认「string 是谁放宽的」,
再按这张卡选药——顺序永远是先诊断、后开方。
这张卡也是「悬停驱动诊断」的又一次实战:五张处方的选择全部建立在 悬停结果之上(是 string?是 readonly?是元组?),没有一次悬停是白按的。
7. 这一章在全书链条中的位置¶
可变性是「注解与推断」这对主角的隐藏剧本:第 04 章说「变量通常不用注解」, 本章补上了例外的一半——当推断被可变性拉宽、而你需要窄类型时, 工具从弱到强是:内联、标注、readonly、as const。 「as const 与变量注解掰手腕、变量赢」这个预告,直通第 12 章「注解与断言」的主线; 而 as const 产出的精确字面量类型,又是第 11 章类型推导和第 16 章设计类型时的重要原料。 原书小结把全章压成一条因果链:let → 宽推断;const → 字面量;对象属性例外 → 标注/内联; readonly/Readonly → 类型层面的锁;as const → 深度不可变 + 精确字面量,与 Object.freeze 分属两个层面33。
这条因果链可以反过来读,作为本章的自测:给你一个从未见过的推断结果, 你能顺着链说出它是哪一环的产物吗? 三个练习题:
① 悬停一个变量看到 string,而它声明时明明赋的是 "rock"
→ let 环:变量绑定可变,拓宽了。处方:const 或标注。
② 悬停一个 const 对象的属性看到 string
→ 对象属性环:const 锁不住属性。处方:标注、内联、as const。
③ 一个配置对象类型精确到字面量、属性全带 readonly
→ as const 环:有人上了深锁。处方:无需处理;若下游报「只读不能给可变」,
检查传染链上是不是该有个 readonly 参数。
图说:正着读是「机制」,反着读是「诊断」。前者用于学习,后者用于工作——
本章真正值钱的读法是后者。
按这个读法,本章和前后章的接口也清楚了:第 06 章教你「if 检查让编译器收窄」, 本章教你「声明方式决定编译器给你多宽的起手牌」——两个都是「收窄」, 只是一个发生在运行时检查之后,一个发生在声明那一刻;第 12 章会补上第三个: 「注解和断言怎么在声明之后再改一次牌」。三处合起来,才是「一个变量的类型 由什么决定」的完整答案——声明定起点,检查定途中,注解定合同,本章管的是起点。 第八章的可变性,到此拆完;下一章进类:同样的「谁可写」问题,在 class 里多出一整层修饰符语法,答案也更长了——构造器、继承、访问控制,一个都躲不开。
8. 作者的判断与证据(分开标)¶
书里有据的:
- let 拓宽、const 保持字面量——语言语义事实,机制解释是「可能被重赋值」13。
- const 对象的属性照常被拓宽——由「对象在 JavaScript 里可变」支撑6。
- 只读/可变数组的单向规则——可验证的语言规则,作者给了双向实验17。
- readonly 对象属性的洞是「早年的有意决定」——作者明说是 known limitation,并提到未来可能收紧22。
- as const 优于 Object.freeze(除运行时冻结需求外)——工程主张,理由是层面、深度、成本三重差别30。
原书没说的(我们补):
- 「widen(拓宽)」这个术语本身原书用了「widens it to a wider type」1但未作为术语收编; 在学界与 TypeScript 文档里它就叫 widening,常和「narrowing」对举。
- Object.freeze 的深层不变式需要递归 freeze 或 deep-freeze 库——补充(来自通用知识): JavaScript 的 freeze 天然只锁一层,这是语言行为,不是 TypeScript 的限制。
判断(我们的,不是书里的): 本章最值得记住的不是任何单个工具,而是那条贯穿的因果: 推断的宽窄,是「未来会不会被改写」的函数。 TypeScript 不做价值判断, 它只做一件事——你给它的每个「可写性」承诺,它都折算成类型宽度还给你。 所以诊断「TypeScript 为什么把这个推断成 string」时,永远先问「它认为这东西以后谁会写、在哪写」, 九成答案都在那里。这是把第 04 章「推断」从规则升级成工程直觉的一步。 如果错,会错在: 随着编译器分析的进步(比如跨函数的数据流分析增强), 某些「为了可写性留宽」的保守推断可能变得更精确,这个「先问谁会写」的启发式会漏掉 少数「编译器其实知道你以后不会写」的新场景——但作为心智模型,它仍然是对的近似。
判断(我们的,不是书里的): 「只读传染」(readonly 在调用链上的传播要求) 是本章最反直觉、却也最被低估的机制。它把「可变性」从实现细节提升成了 函数签名的一部分:接受只读参数的函数越多,整个代码库的「无意外改写」 保证就越完整——传染不是缺陷,是一条自下而上建立的全链路契约。 实践中值得推一把:写新函数时,默认从 readonly 参数开始, 发现真需要改再放宽;反向(从可变开始、事后加锁)几乎从不会发生。 如果错,会错在: 如果项目大量依赖第三方库的可变数组签名 (老库的
T[]形参),readonly 传染会在边界处频繁碰壁, 此时「默认只读」的纪律成本高于收益——边界摩擦程度决定这条纪律的适用性。判断(我们的,不是书里的): as const 与 Object.freeze 的对照 其实是「类型系统 vs 运行时」这一全书主线的一个缩影:一个完全在编译期 (零成本、深度、无运行时痕迹),一个完全在运行期(有真实动作、只一层、能被发现)。 记住这个对照的意义不在二选一,而在遇到任何「我想要 X 不变」的需求时, 先问一句「这个不变要在哪个世界成立」——两个世界的工具各管一段,混用常常两头落空。 如果错,会错在: 安全敏感的场景(防篡改)恰好需要运行时冻结, 此时「as const 全面优于 freeze」不成立——原书自己留了「除非你需要运行时冻结顶层」 的例外,我们与此一致。
9. 边界与局限¶
- readonly 全部可被擦除:运行时不存在任何只读检查;「只读」承诺只在你全程留在 TypeScript 编译范围内时有效——纯 JavaScript 调用方照样改。
- readonly 对象属性的洞(上一节)是本章最大的「别全信」:它随时可能被语言版本收紧, 依赖这个洞通过的代码将来会报错。
- as const 的「传染」成本:深度只读类型传进第三方库的可变参数时经常碰壁, 实践中的折中是粒度 3(只锁字面量)。
- Readonly/Partial 等同款「只一层深」的家族特性,第 07 章已经见过一次——这是 TypeScript 工具类型的系统性边界,不是哪一个工具的 bug。
- 「可变 → 只读」的方向容易,「只读 → 可变」没有回头路。 一个对象标了深锁之后, 想放松只有重写类型一条路(as const 的产物无法被「解锁」回可变)。 所以上锁前的最后一问应该是「下游有没有需要改它的场景」——这比锁本身难撤销。
- 本章所有机制都不改变运行时:拓宽、收窄、readonly、as const, 全部是编译期的推断与检查。运行时的对象该 mutable 还是 mutable—— 这也是第 03 章「薄语法」原则在本章的一次完整演练:四站走完,没产生一行运行代码。
最后一条边界是视角性的:本章教的是「怎么让类型更窄、更精确」, 但窄不是目的,精确才是。一个被 as const 锁死的配置对象,如果它的消费方 (框架、第三方库)期望可变形状,过窄的类型就是阻力而非助力。 本章的全部工具都应该服务于「形状的诚实表达」,而不是「锁的越多越好」—— 这个取向在第 17 章的复杂度预算里还会再见到一次。
如果这个取向和原书的立场有任何出入,那也只在强调的分量上: 原书把工具讲全了,把「什么时候别用」散在各处;我们把后者聚在这一条里, 因为它最容易被「工具章」的兴奋感盖过去——锁是负债和资产的双重身份, 本章教的全部是它的资产面,这一条是它的负债面,两面都要记账。
10. 可带走的¶
- let → 宽推断;const → 字面量推断;能 const 就 const。
- const 锁变量不锁属性:对象/数组的属性照样被拓宽成
string这类宽类型。 - 属性太宽的解法从轻到重:调用处内联 → 变量标注 → readonly → as const。
- readonly 与 Readonly<T> 只作用类型层面、只作用一层深。
- 可变数组 → 只读参数 ✅;只读数组 → 可变参数 ❌。 readonly 会传染,传染是特性。
- 元组默认标
readonly——防「数组形参 pop 元组」的误伤。 - readonly 对象属性有洞:可被可变函数偷改,别全信。
as const= 深度只读 + 字面量推断,双份回报;与变量注解同场,变量赢。- 要字面量但不要锁:
type: "button" as const(粒度 3)。 - as const 全面优于 Object.freeze,除非你真要运行时冻结。
- 诊断「为什么推断成 string」先问一句:它认为这东西以后谁会写、在哪写?
- 选锁的轻重视「写者有几个」:只有自己读 → 内联/const;被很多人传 → readonly 参数;发布即冻结 → as const。
这 12 条里,前 6 条是「规则层」(背下来直接用),7–10 条是「工具层」(知道有什么可用), 11–12 条是「诊断层」(出问题怎么办)——三层各管一段,合起来才是本章的完整输出。 值得强调的是第 11 条的位置:它是唯一能「生成」其余 11 条的原理, 真懂了它,前面十条忘了也能现场推出来。学本章的力气分配建议:第 11 条吃透, 其余浏览留痕。
最后交代本章的读法:四站主线(第 2–5 节)是正文,按顺序读一遍即可; 四个练习走查(第 6 节)是答案集,遇到具体报错时回来查表; 诊断卡(第 6 节末)是随身工具,值得单独收藏。三层各读一遍,本章就物尽其用了。
11. 原文地图¶
| 主题 | 原书位置 | 原文位置 |
|---|---|---|
| let 的拓宽 | Mutability and Inference | text/23-fm-mutability-and-inference.txt:31(搜「widens it to a wider type」) |
| let 的标注解法 | 同上 | text/23-fm-mutability-and-inference.txt:35(搜「AlbumGenre = "rock"」) |
| const 的字面量推断 | 同上 | text/23-fm-mutability-and-inference.txt:50(搜「const albumGenre = "rock"」) |
| 「借 JavaScript 语义做推断」 | 同上 | text/23-fm-mutability-and-inference.txt:65(搜「mirroring JavaScript」) |
| const 对象属性仍被拓宽 | 同上 | text/23-fm-mutability-and-inference.txt:101(搜「inferred the status property as a string」) |
| 属性拓宽的报错拆解 | 同上 | text/23-fm-mutability-and-inference.txt:96(搜「not assignable to parameter」) |
| 内联对象的收窄推断 | 同上 | text/23-fm-mutability-and-inference.txt:114(搜「infers it more narrowly」) |
| readonly 修饰符 | 同上 | text/23-fm-mutability-and-inference.txt:138(搜「readonly title」) |
| Readonly 工具类型 | 同上 | text/23-fm-mutability-and-inference.txt:154(搜「readOnlyWhiteAlbum」) |
| 「只作用类型层面」 | 同上 | text/23-fm-mutability-and-inference.txt:146(搜「only on the type level」) |
| Readonly 只一层深 | 同上 | text/23-fm-mutability-and-inference.txt:166(搜「first level」) |
| 只读数组与 push 消失 | 同上 | text/23-fm-mutability-and-inference.txt:184(搜「does not exist on type」) |
| 单向通行规则 | 同上 | text/23-fm-mutability-and-inference.txt:220(搜「the reverse is not true」) |
| 「readonly 会传染」 | 同上 | text/23-fm-mutability-and-inference.txt:233(搜「spread virally」) |
| 只读元组防 pop 误伤 | 同上 | text/23-fm-mutability-and-inference.txt:429(搜「readonly [number, number]」) |
| readonly 对象属性的洞 | 同上 | text/23-fm-mutability-and-inference.txt:266(搜「known limitation」) |
| 「偶尔是可变的」警告 | 同上 | text/23-fm-mutability-and-inference.txt:270(搜「occasionally mutable」) |
| as const 的字面量+只读双回报 | 同上 | text/23-fm-mutability-and-inference.txt:480(搜「deeply read-only」) |
| as const vs 变量注解(变量赢) | 同上 | text/23-fm-mutability-and-inference.txt:499(搜「variable annotation wins」) |
| as const vs Object.freeze | 同上 | text/23-fm-mutability-and-inference.txt:582(搜「runtime immutability」) |
| as const 的三种粒度 | 同上 | text/23-fm-mutability-and-inference.txt:639(搜「as const;」) |
| 本章小结 | 同上 | text/23-fm-mutability-and-inference.txt:720(搜「infers wider types」) |
-
出处:「Mutability and Inference」第 21–31 段(
text/23-fm-mutability-and-inference.txt:31,搜「widens it to a wider type」)。 ↩↩↩ -
出处:「Mutability and Inference」第 33–42 段(
text/23-fm-mutability-and-inference.txt:35,搜「let albumGenre: AlbumGenre」)。 ↩↩ -
出处:「Mutability and Inference」第 44–67 段(
text/23-fm-mutability-and-inference.txt:50,搜「const albumGenre = "rock"」);重赋值报错见第 63 段(搜「because it is a constant」)。 ↩↩ -
出处:「Mutability and Inference」第 65 段(
text/23-fm-mutability-and-inference.txt:65,搜「mirroring JavaScript」)。 ↩ -
出处:「Mutability and Inference」第 67 段(
text/23-fm-mutability-and-inference.txt:67,搜「encourage you to use const」)。 ↩ -
出处:「Mutability and Inference」第 87–104 段(
text/23-fm-mutability-and-inference.txt:101,搜「inferred the status property as a string」);「对象可变」的前提见第 73 段(搜「mutable in JavaScript」)。 ↩↩ -
出处:「Mutability and Inference」第 95–99 段(
text/23-fm-mutability-and-inference.txt:99,搜「not assignable to type」)。 ↩ -
出处:「Mutability and Inference」第 108–114 段(
text/23-fm-mutability-and-inference.txt:114,搜「infers it more narrowly」)。 ↩ -
出处:「Mutability and Inference」第 116–127 段(
text/23-fm-mutability-and-inference.txt:124,搜「reassigned only to a valid value」)。 ↩ -
出处:「Mutability and Inference」第 272–343 段(
text/23-fm-mutability-and-inference.txt:310,搜「something strange」)。 ↩↩ -
出处:「Mutability and Inference」第 129–146 段(
text/23-fm-mutability-and-inference.txt:138,搜「readonly title」)。 ↩ -
出处:「Mutability and Inference」第 148–164 段(
text/23-fm-mutability-and-inference.txt:154,搜「readOnlyWhiteAlbum」)。 ↩ -
出处:「Mutability and Inference」第 146 段(
text/23-fm-mutability-and-inference.txt:146,搜「only on the type level」)。 ↩ -
出处:「Mutability and Inference」第 166 段(
text/23-fm-mutability-and-inference.txt:166,搜「first level」)。 ↩ -
出处:「Mutability and Inference」第 168–179 段(
text/23-fm-mutability-and-inference.txt:175,搜「ReadonlyArray」)。 ↩ -
出处:「Mutability and Inference」第 181–195 段(
text/23-fm-mutability-and-inference.txt:184,搜「does not exist on type」);map 可用见第 186–188 段(搜「create a copy」)。 ↩↩ -
出处:「Mutability and Inference」第 197–235 段(
text/23-fm-mutability-and-inference.txt:220,搜「the reverse is not true」)。 ↩↩ -
出处:「Mutability and Inference」第 233 段(
text/23-fm-mutability-and-inference.txt:233,搜「spread virally」)。 ↩ -
出处:「Mutability and Inference」第 345–392 段(
text/23-fm-mutability-and-inference.txt:373,搜「readonly string[]」)。 ↩ -
出处:「Mutability and Inference」第 394–448 段(
text/23-fm-mutability-and-inference.txt:443,搜「not assignable to parameter of type 'number[]'」)。 ↩ -
出处:「Mutability and Inference」第 448 段(
text/23-fm-mutability-and-inference.txt:448,搜「as much as possible」)。 ↩ -
出处:「Mutability and Inference」第 237–270 段(
text/23-fm-mutability-and-inference.txt:266,搜「known limitation」);「可能未来修改」见第 268 段(搜「may be changed」)。 ↩↩↩ -
出处:「Mutability and Inference」第 270 段(
text/23-fm-mutability-and-inference.txt:270,搜「occasionally mutable」)。 ↩ -
出处:「Mutability and Inference」第 450–465 段(
text/23-fm-mutability-and-inference.txt:465,搜「staff-pick」)。 ↩ -
出处:「Mutability and Inference」第 467–487 段(
text/23-fm-mutability-and-inference.txt:477,搜「readonly status」)。 ↩ -
出处:「Mutability and Inference」第 480–485 段(
text/23-fm-mutability-and-inference.txt:485,搜「large config objects」)。 ↩ -
出处:「Mutability and Inference」第 487 段(
text/23-fm-mutability-and-inference.txt:487,搜「only the type level」)。 ↩ -
出处:「Mutability and Inference」第 489–506 段(
text/23-fm-mutability-and-inference.txt:499,搜「variable annotation wins」)。 ↩ -
出处:「Mutability and Inference」第 508–580 段(
text/23-fm-mutability-and-inference.txt:551,搜「solely at the first level」)。 ↩↩ -
出处:「Mutability and Inference」第 582 段(
text/23-fm-mutability-and-inference.txt:582,搜「stick with as const」)。 ↩↩ -
出处:「Mutability and Inference」第 687–714 段(
text/23-fm-mutability-and-inference.txt:714,搜「infer a literal type」)。 ↩ -
出处:「Mutability and Inference」第 628–650 段(
text/23-fm-mutability-and-inference.txt:649,搜「readonly ButtonAttributes[]」)。 ↩ -
出处:「Mutability and Inference」第 716–732 段(
text/23-fm-mutability-and-inference.txt:720,搜「infers wider types」)。 ↩ -
出处:「Mutability and Inference」练习 7-1 解答(
text/23-fm-mutability-and-inference.txt:305,搜「type: string」)。「your array is mutable」见第 308 段。 ↩ -
出处:「Mutability and Inference」练习 7-1 的两个修法(
text/23-fm-mutability-and-inference.txt:321,搜「ButtonAttributes[] = [」)与第 334 段(搜「directly to the modifyButtons function」)。 ↩ -
出处:「Mutability and Inference」练习 7-2(
text/23-fm-mutability-and-inference.txt:354,搜「unused "@ts-expect-error" directive」)。 ↩ -
出处:「Mutability and Inference」练习 7-2 解答(
text/23-fm-mutability-and-inference.txt:387,搜「readonly string[] when hovering」)。 ↩ -
出处:「Mutability and Inference」练习 7-3(
text/23-fm-mutability-and-inference.txt:398,搜「dangerousFunction」)与现状描述(搜「pop removes the last element」)。 ↩ -
出处:「Mutability and Inference」练习 7-3 解答(
text/23-fm-mutability-and-inference.txt:444,搜「is 'readonly' and cannot be assigned」)。 ↩ -
出处:「Mutability and Inference」第 448 段(
text/23-fm-mutability-and-inference.txt:448,搜「as much as possible」)。 ↩ -
出处:「Mutability and Inference」练习 7-4 的三个解法(
text/23-fm-mutability-and-inference.txt:645,搜「cannot be assigned to the mutable type」;:665,搜「the array itself is not」;:714,搜「allow mutation but still want to infer a literal type」)。 ↩↩↩