跳转至

怪异的部分 — evolving any、开放对象与两个世界

这一章讲一件事: TypeScript 那些「咦,怎么这样」的行为,背后都是设计决定。 对象是开放的、类型和值是两个世界、函数可以少参数——三条主线, 串起 evolving any、多余属性检查、Object.keys 的固执、空对象类型的怪名、 跨界的 class 与 enum、this 的类型用法、函数联合的收成交集。 原书的期望写在结尾:「希望这一章显得标题起错了——你看完不觉得怪, 只觉得这些是设计上的必然。」1

1. 顶层全景:三个心智模型统摄全章

  模型一:对象是开放的
    → 多余属性只在「新鲜对象」上报错(陈旧对象放行)
    → Object.keys 只能返回 string[]
    → {} 不是「空对象」,是「除 null/undefined 外的任何东西」

  模型二:类型世界 vs 值世界
    → 大多数名字只住在一个世界;类型不能当值用,值不能当类型用
    → 例外:class、enum、this、同名 type+value 可以跨界

  模型三:函数可赋值性
    → 实现方参数可以比声明少(忽略多余的参数是合法的)
    → 函数联合的参数收成交集 → 不兼容时退化成 never

  图说:本章每个怪癖,都挂在其中一根钉子上。记住钉子,怪癖变成推论。

2. evolving any:类型会「进化」的变量

解决什么问题: JavaScript 变量经常「先空着、用的时候再变」。普通 TypeScript 变量做不到(类型是静态的),但有一个例外形态:

let myVar;            // 悬停 → let myVar: any
myVar = 659457206512;
console.log(myVar.toExponential()); // ✅ 数字的方法

myVar = "mf doom";
console.log(myVar.toUpperCase());   // ✅ 字符串的方法

只写 let 不标类型,TypeScript 给的是 any——但不是普通的 any。 普通 any 上任何方法都放行(第 04 章);evolving any 上, 每次赋值之后,变量的类型就「进化」成新值的类型:

myVar = 659457206512;
myVar.toUpperCase(); // ❌ Property 'toUpperCase' does not exist on type 'number'.

赋值成数字之后,它就是 number——number 没有的方法,照样报错2。 原书称之为「一种高级形式的收窄」:类型跟着赋值动作,一步步收窄到当前值的类型3。

先做个对照实验,看清 evolving any 和普通 any 的分界:

  let a: any = 123;
  a.toUpperCase();  // ✅ 不报错——普通 any 对一切方法放行(第 04 章)

  let b;            // evolving any
  b = 123;
  b.toUpperCase();  // ❌ 报错:b 此刻是 number

区别在「是否被赋值驯化」:显式标注的 any 是「永久豁免权」, evolving any 是「临时的、随赋值更新的诚实记录」。原书那句「This doesn't work like a regular any」指的正是这条分界2。

数组版本更精彩,也是实战中最常用的形态:

const evolvingArray = [];  // any[]

evolvingArray.push("abc");        // string[]
evolvingArray.push(123);          // (string | number)[]
evolvingArray.push("do re mi");   // (string | number)[]
evolvingArray.push({easy: true}); // (string | number | {easy: boolean})[]

空数组起步,每次 push,数组的元素并集就扩一圈——类型从你的操作序列里被推出来4。

把数组进化多看一层,它的机制和第 06 章的「收窄」其实同构——都是「类型跟随证据更新」:

  收窄(第 06 章):值不变,检查添加证据 → 类型在代码块内变窄
  进化(本节):  检查(赋值/push)就是值本身的更新 → 类型跟着值的历史变

  两者的共同前提:编译器只信任「已经发生的事」。
  收窄信的是「if 里的检查发生了」;进化信的是「赋值/push 发生了」。
  而它们的对称盲区也相同:未来的事一概不信——
  进化后的变量重赋值成新类型,类型立刻再改写;想锁定,只有显式标注。

  图说:收窄和进化是同一枚硬币的两面——收窄「验证后收紧」,进化「操作后重写」。
  学会一个,另一个的边界也就清楚了。

这个同构关系解释了为什么 evolving any 的上手感觉「似曾相识」: 它就是把第 06 章的收窄逻辑应用到了「声明即历史」的场景。 也给出了它的使用边界:变量的一生里,如果类型改写的次数 ≤ 3, evolving any 是捷径;超过 3 次,显式并集标注(第 06 章)读起来更省—— 改写次数就是它的复杂度预算,和第 17 章 /utils 层的预算思维同构。

判断(我们的,不是书里的): evolving any 是「渐进类型」哲学的极端样本: 它本质上是在类型系统里为 JavaScript 的动态惯用法留的一条暗道。 实用建议:它是脚本和数据处理管道的好朋友(逐行解析、逐步聚合的场景极其顺手), 但在应用代码里要克制——它的类型是「历史操作的记录」,不是「契约」, 重构时没有任何地方能告诉你「这个变量应该是什么形状」。 一个团队准则:evolving any 出现的文件,值得多看一眼它是不是该写成显式类型。 如果错,会错在: 在快速原型和一次性数据处理里,显式类型的契约价值接近零, 而 evolving any 的开发速度快得多——「该写显式类型」的阈值随项目寿命上升, 本条建议对短命代码是过度要求。

evolving any 与第 08 章「拓宽」的正面对照

把本章的 evolving any 和第 08 章的「拓宽(widening)」并排,一个常被混淆的 点对点就清楚了——它们方向相反:

  拓宽(第 08 章):声明时给了宽类型 → 之后赋值收窄,编译器跟着走
    let x: string | number = "a"; x = 1;   // 合法,x 的类型不变,值在槽内换

  进化(本章):声明时没给类型 → 每次赋值,类型本身改写
    let y; y = "a"; y = 1;                 // 合法,类型先 string 后 number

  图说:拓宽是「槽已定,换值」;进化是「槽随值变」。
  前者是静态类型的常规;后者是类型系统对动态惯用法的让步。

这个对照也解释了为什么「let 不标类型」是个双刃写法:它在脚本阶段给你 JavaScript 的自由,在应用阶段拿走 TypeScript 的保障。同一段代码, 换场景就是换评价——诊断「这里该不该加类型标注」时,先问这段代码的 预期寿命和读者数量,答案比任何规则都准(这条判据和第 17 章的复杂度预算同构: 预算 = 寿命 × 读者数)。

3. 多余属性警告:新鲜对象被盘查,陈旧对象放行

本章第一个「咦」。接口 Album 只要 title 和 releaseYear:

const rubberSoul = {
  title: "Rubber Soul",
  releaseYear: 1965,
  label: "Parlophone",   // 接口里没有的「多余属性」
};

processAlbum(rubberSoul); // ✅ 不报错!

先存变量再传——多余属性安然无恙5。把两次悬停放在一起看,命运的岔路口很清楚:

  路径 A(先存变量):
    const rubberSoul = { title, releaseYear, label };
    processAlbum(rubberSoul);      // ✅
    悬停 rubberSoul → { title: string; releaseYear: number; label: string }
    ——推断保留了 label;传给 processAlbum 时,比较发生在
      「一个已有类型」和 Album 之间 → 陈旧对象 → 放行

  路径 B(内联字面量):
    processAlbum({ title, releaseYear, label });
    ——比较发生在「这个字面量」和 Album 之间 → 新鲜对象 → 报错
    报错:Object literal may only specify known properties,
          and 'label' does not exist in type 'Album'.

两条路径的区别只在比较发生在哪一侧,而不在对象的内容—— 这就是「检查跟着字面量走」的精确含义56。

但如果内联:

processAlbum({
  title: "Rubber Soul",
  releaseYear: 1965,
  label: "Parlophone", // ❌ Object literal may only specify known properties
});

同一个对象,两种命运。区别的正式名称是新鲜度(freshness):

  • 新鲜对象(fresh object):在赋值现场直接写出来的字面量——声明变量时, 或内联进函数调用时。新鲜对象接受多余属性检查:字面量里的每个键都必须在 目标类型里存在,否则报错6。
  • 陈旧对象(stale object):先赋给变量、或从函数返回的对象。陈旧对象 不做多余属性检查,按开放对象对待7。

设计理由(原书给了两段):陈旧对象可能在别处还要用到那个多余属性, TypeScript 不想挡你的路;而内联对象「肯定不会在别处用到」,查一查是净收益8。 所以推论很反常识:TypeScript 并不是「在乎」多余属性——它只在乎「你现在写的这个字面量」里的多余属性。

这个行为的代价有一个经典受害者场景:可选参数名拼错。

const fetch = (options: {url: string; timeout?: number}) => { /* ... */ };

const options = {
  url: "/",
  timeOut: 1000,   // 拼错了!timeout 少了个字母,变成多余属性
};

fetch(options); // ✅ 不报错 —— 陈旧对象放行
// 运行时:timeout 永远是 undefined,行为静默错误

唯一的办法是给 options 变量加上类型注解——注解让对象在赋值现场变「新鲜」, 拼错的键立刻现形9。「可选参数不生效、且无报错」排查第一步:查拼写。

「拼写漏网」的完整事故树与三档防线

这个 misspelled 案例是全书最典型的「类型系统安静地帮倒忙」场景,值得把事故树 和防线画全:

  事故树:
    ① 写代码:options 对象手抄了参数名(timeOut vs timeout)
    ② 传参:fetch(options) —— 陈旧对象放行,编译器沉默
    ③ 运行时:timeout 永远是 undefined,功能静默失效
    ④ 排查:无报错可追,只能从「功能不生效」倒推

  三档防线(按成本从低到高):
    第一档:调用处内联对象(零成本)
      fetch({ url: "/", timeOut: 1000 });  → 拼错立刻报错(新鲜字面量)
      适用:一次性调用。
    第二档:变量标注(一次成本)
      const options: FetchOptions = { url: "/", timeOut: 1000 }; → 声明处报错
      适用:options 会被复用。
    第三档:satisfies(第 12 章工具,两全)
      const options = {...} satisfies FetchOptions; → 检查 + 推断保留
      适用:既想复用对象,又需要它原本的精确推断(如 keyof 推导)。

  图说:三档防线对应对象「用几次、要不要精确推断」两个维度的组合;
  漏网的共同原因是「先存变量、忘了标注」——事故树的 ② 正是这一步。

三档防线的选择逻辑,和第 12 章的「沟通协议卡」完全同构(检查失败 → 找依据 → 选工具)。misspelled 这类事故的特征是「静默」——它不产生报错,只产生行为 偏差,所以等不到编译器救你,只能靠防线前置。把「options 对象要不要标注」 从个人习惯升级成团队代码风格(lint 规则可强制),是这类事故唯一可靠的终结方式。

函数比较时也不查多余属性

第二种不查的场景:把函数传给函数时,返回对象的多余属性不查:

const newAlbums = remapAlbums(albums, (album) => ({
  ...album,
  releaseYear: album.releaseYear + 1,
  strangeProperty: "This is strange",   // ✅ 不报错!
}));

没有返回类型注解时,TypeScript 在比较两个函数,而函数之间的比较 不触发多余属性检查——于是每个 Album 运行时都多一个 strangeProperty, 编译器还以为返回的是干净的 Album[]10。修复:给内联回调加返回类型注解 ((album): Album => ...),返回的字面量变「新鲜」,检查恢复11。

开放 vs 封闭:与 Flow 的对照

TypeScript 的对象默认开放(open)——任何对象都可能有多余属性。 Meta 的 Flow 走了另一条路:对象默认封闭(exact),多一个属性直接报错, 要用 ... 语法显式声明「允许开放」12。

原书明说了立场:TypeScript 的选择更合理——开放对象更贴近 JavaScript 的真实 运行方式;在这么动态的语言上,任何类型系统自称「绝对安全」都值得警惕13。 但团队也做了折中(下一节的 fresh/stale),原书称之为两全:比 Flow 的封闭宽松, 又在你「亲手写字面量」的瞬间给出你想要的警告14。

「开放」的世界观代价与收益清单

开放对象这个决定是本章全部怪癖的总根源,值得把它的代价和收益摆全:

  收益(为什么该开放):
    ① JSON 反序列化多出来的字段、框架注入的属性、扩展对象——全部无缝通过
    ② 库升级加字段,消费方代码不报错(封闭对象会全线爆炸)
    ③ 与 JavaScript 的鸭子类型心智完全对齐——学习成本零

  代价(为什么它制造怪癖):
    ① 多余属性检查只能放在「字面量现场」(fresh/stale 二分)
    ② Object.keys 只能返回 string[](键集不可枚举,第 4 节)
    ③ {} 变成「非空」而非「空对象」(第 5 节)
    ④ 拼错的可选参数名在陈旧对象上漏网(第 3 节的开头)

  图说:四条代价,三条是「键集不可知」的推论,一条是「检查时机」的限制——
  同一个决定(开放)在不同地形上的投影。认清总根源的好处:
  遇到一个没见过的怪癖,先问「它是不是开放对象的投影」,八成是。

这个「总根源视角」也是本章的阅读策略:不必把六个怪癖当六个孤立事实背, 记住「一切皆开放」一个决定,六个怪癖全是它的推论——这是本章从「怪癖集」 升级成「世界观」的关键一跃,也是原书把 Flow 对照放在这里的深意: 两个世界观的分岔,解释了后面所有行为差异的走向。

练习 12-2 走查:同一个对象的三种「变新鲜」方式

练习 12-2 把 fresh/stale 玩成了一道应用题:options 对象带着多余属性 search,上面的 @ts-expect-error 因为不报错而自己亮了红线(预期落空)。 任务是「让多余属性检查重新生效」,标准答案有三个,恰好对应三种「把对象变新鲜」的姿势:

// 姿势一:变量注解——赋值现场变新鲜
const options: FetchOptions = { url: "/", /* ..., */ search: new URLSearchParams() };
//                                                    ↑ 多余属性检查恢复,@ts-expect-error 兑现

// 姿势二:satisfies——检查不动推断
const options = { url: "/", /* ..., */ search: new URLSearchParams() } satisfies FetchOptions;

// 姿势三:内联到调用处——字面量直接出现在参数位置
myFetch({ url: "/", /* ..., */ search: new URLSearchParams() });

三种姿势的检查原理完全相同(字面量 vs 类型的直接比较),选用看场景: 姿势一最直白;姿势二要接着用 typeof 推导键的类型;姿势三适合一次性调用51。 这道题的真正考点是:@ts-expect-error 是个测试断言,断言失败要先问「被测行为 为什么没发生」——在这里就是「对象为什么不新鲜」。

练习 12-3 走查:map 回调的盲区

const usersWithIds: User[] = users.map((user, index) => ({
  ...user,
  id: index,
  age: 30,  // @ts-expect-error 期望报错,但没报
}));

age 不是 User 的属性,为什么检查不生效?把 §3 的第三种不查场景套上来: map 回调的返回值没有类型注解时,TypeScript 在比较的是「推断出的返回类型」和 User[]——两个类型(函数产出的)之间的比较,不是字面量和类型的比较。 返回值此刻是「陈旧」的,多余属性放行50。

两个修复,各对应一种「让返回值变新鲜」:

// 修复一:给回调标返回类型
users.map((user, index): User => ({ ...user, id: index, age: 30 }));
//                                                ↑ 现在报错,@ts-expect-error 兑现

// 修复二:satisfies 落在返回的字面量上
users.map((user, index) => ({ ...user, id: index, age: 30 }) satisfies User);

原书对这类盲区的防御守则是:「可能含多余属性的变量,总是显式声明类型; 函数,总是显式声明预期返回类型」50。这句守则是全章性价比最高的一条工程纪律。

判断(我们的,不是书里的): fresh/stale 机制是「实用主义类型设计」的教科书案例: 它把「严格性」精准地投放到了「开发者最可能犯错的位置」(亲手写的字面量), 而把「灵活性」保留给了「系统的接缝处」(变量传递、库边界)。 代价是机制本身必须被学习——本章的存在就是这个代价。Flow 的封闭默认在理论上更干净, 但在「和 JavaScript 生态共存」这个实际考题上输了。 如果错,会错在: 随着 TypeScript 控制流分析增强,未来可能对陈旧对象也做 有限的多余属性检查(社区确实有过提案);若成真,「陈旧对象放行」将变成 「仅在跨模块边界放行」,本章的机制描述要收窄——但 fresh/stale 的二分本身 大概率保留,因为它服务的是「字面量检查」这个真实需求。

4. 对象键是松散类型:Object.keys 的固执

开放对象的直接后果:编译器不能假设对象的键就那么多——万一有多余属性呢?

const yetiSeason = { title: "Yeti Season", artist: "El Michels Affair", releaseYear: 2021 };

const keys = Object.keys(yetiSeason); // 类型:string[]

Object.keys 的返回类型是 string[],不是 (keyof typeof yetiSeason)[]。 于是用这些键去索引对象,撞墙:

keys.forEach((key) => {
  console.log(yetiSeason[key]);
  // ❌ expression of type 'string' can't be used to index type '{title...}'
});

string 太宽了——它可能不是这个对象的键。唯一合法的键类型是 'title' | 'artist' | 'releaseYear'(keyof typeof),而 keys 不是它15。 for...in 循环同样的问题16。

把「string 太宽」翻译成类型语言,这个报错其实是宽窄规则(第 06 章)的又一次值班:

  索引访问 obj[key] 的类型规则:key 的集合必须「窄进」obj 的键集合。
    keys: string[]          → 键集合是「任意字符串」(宽)
    yetiSeason 的键集合     → "title" | "artist" | "releaseYear"(窄)
    宽进窄 → 拒绝(和第 06 章「宽进窄不行」一字不差)

  Object.keys 的设计选择:返回宽类型 string[] 而非窄类型 (keyof T)[],
    是 TypeScript 团队对「保护优先」的又一次表态——
    宁可让合法的窄键访问报错(逼你显式收窄),也不让宽键访问在运行时悄悄落空。
    这个选择和开放对象世界观一脉相承:键集不可枚举,宽键类型是唯一诚实的回答。

  图说:报错不是刁难,是宽窄规则在键位置的应用;
  「为什么不让 Object.keys 返回 keyof T[]」的答案:那会谎报键的安全性,
  一旦对象形状漂移(第 3 节的陈旧对象放行),遍历就会漏字段——string[] 至少诚实。

「诚实胜过方便」是这个设计的三连注脚:开放对象诚实(至少包含)、Object.keys 诚实 (键集宽)、函数参数诚实(少收合法)——三个设计共享同一个价值观, 这就是原书所谓「可预期的推论」的真正含义:不是随机的妥协,而是同一条原则 在不同地形的贯彻。读者一旦认出这条贯穿线,剩下的「怪癖」就只是这条线的 新段落,而不是新的惊吓。

这条贯穿线也值得和第 10 章的「JavaScript with types」对照着看: 第 10 章讲的「薄语法」是这条线的正面表述(类型只加注解、不改语义), 本章讲的三个「放弃」是它的反面表述(为了保证正面成立,类型系统必须 放弃一部分理想化的能力)。正面与反面合起来,才是 TypeScript 设计哲学的完整 形状——「贴着 JavaScript 走」既是它的力量来源(零学习成本、生态兼容), 也是它的妥协来源(开放对象、string[] 键、少参数合法)。两个来源是同一枚硬币, 拆开任何一面讲都会讲偏——这是本章与第 10 章合读时才完整的一次论证, 也是原书把两章分得这么远(第 10 章与第 13 章)却暗中呼应的又一例。

原书强调:这份固执是保护,不是刁难。 看一个反例——用 as 强绕过去之后:

const staleAlbum: Album = yetiSeason;   // 陈旧的,带 releaseYear

for (const key in staleAlbum) {
  // 编译时不报错;运行时炸:
  // "staleAlbum[key].toLocaleLowerCase is not a function"
  console.log(staleAlbum[key as keyof Album].toLocaleLowerCase());
}

keyof Album 是 "title" | "artist",可遍历出来的键实际还包括 releaseYear—— 强绕的检查缺口在运行时变成崩溃。枚举键时最安全的默认就是把键都当 string 处理17。

练习 12-4 给了四种遍历的正道,按推荐排序:

  1. Object.values(如果只需要值):绕开键问题 entirely18;
  2. 参数放宽:printUser(obj: Record<string, unknown>)——函数本来就只想 「遍历任意对象的键值」,把参数类型说真话,索引访问自然合法19;
  3. as keyof:断言键是 keyof typeof obj——能用,但接上例的风险, 对象声明类型窄于实际形状时会漏20;
  4. for...in 版同理(key as keyof typeof user)21。
  补充一个第 ⑤ 种选项,2026 年的日常首选:Object.entries
    Object.entries(user).forEach(([key, value]) => { ... });
    一次拿到键和值,键仍是 string(同样的诚实),值是 unknown 级别的宽——
    比 Object.values 多给了键,比 as keyof 安全。配合 Record<string, unknown>
    参数(选项 2)使用,是「键值都要」场景的最优解。
    (补充,来自通用知识:Object.entries 的返回类型是 [string, V][],V 来自参数类型。)

四种正道加一个补充选项,覆盖了遍历的全部需求形状:

需求 首选 次选
只要值 Object.values ——
键值都要 Object.entries(配宽参数) as keyof(有前提)
函数本职是遍历任意形状 放宽参数(选项 2) Object.entries
必须保住原契约且形状可信 as keyof(选项 3/4) for...in 版

把「需求 → 首选」的映射记住,遍历问题从此是查表题而不是推理题—— 这也是本章教法的缩影:每个怪癖背后,都对应一张「需求 → 正解」的映射表; 表背熟了,怪癖就从「惊吓」变成了「索引」。

把四个选项走完整,各自的安全边界就清楚了:

// 选项 1:Object.values —— 不碰键,最安全(只要值不要键时)
function printUser(user: User) {
  Object.values(user).forEach(console.log);
}

// 选项 2:放宽参数类型 —— 函数说真话:「我遍历任意形状」
function printUser(obj: Record<string, unknown>) {
  Object.keys(obj).forEach((key) => {
    console.log(obj[key]);  // ✅ string 索引 Record 完全合法
  });
}

// 选项 3:as keyof —— 键断言,快但接上文的风险
function printUser(user: User) {
  Object.keys(user).forEach((key) => {
    console.log(user[key as keyof User]);  // ✅ 但前提是 user 的形状 == 实际形状
  });
}

// 选项 4:for...in 版同选项 3
function printUser(user: User) {
  for (const key in user) {
    console.log(user[key as keyof typeof user]);
  }
}

选项 1 在「只要值」时是显然最优;选项 2 改变了函数的契约 (从「收 User」变成「收任意对象」),调用方泛了,但函数体也更诚实了; 选项 3、4 保住了契约,代价是键访问的 soundness 缺口(第 4 节开头的崩溃反例)。 练习的加分项「试试拓宽参数类型」,其实就是在推你发现选项 2—— 很多时候「遍历问题」的正确解法不是更聪明的键处理,而是把参数类型放宽成 函数真正需要的样子19。

两条路的取舍(原书原话):「要么让键访问稍微不安全(as keyof typeof), 要么放宽被索引的类型——两条都能用,看你的场景。」22

遍历问题的「决策树」版本

把四个选项收成一个决策树,实战时按顺序问三个问题:

  问 ①:我只需要值,不需要键吗?
    是 → Object.values(选项 1),结束。零风险,首选。
  问 ②:这个函数的本职是「操作 User」还是「遍历任意形状」?
    后者 → 放宽参数(选项 2):Record<string, unknown> 或 object,
           让签名说真话。结束。
    前者 → 进入问 ③。
  问 ③:对象的「声明形状」和「运行时形状」能保持一致吗?
    能(类型是单一事实源,无外部注入) → as keyof(选项 3/4)可用。
    不能(接口来自边界、可能有多余字段) → 回到选项 1 或 2,
           或者承认「这个函数需要运行时校验」——类型层面的遍历到此为止。

  图说:三个问题把四个选项整理成「默认安全、逐步放宽」的顺序;
  as keyof 从「第一手段」降级成了「最后一道」,这是全章最重要的排序修正。

这个排序修正的价值在真实项目里立竿见影:新手的本能是选项 3(as keyof 一行解决), 而它也是四个选项里唯一制造过运行时崩溃的(第 4 节开头的 toLocaleLowerCase 反例)。 「一行解决」和「正确解决」在类型世界里经常分叉——决策树的意义, 就是把两者在「问 ②」处重新合流:签名说真话的选项 2,往往比强绕的选项 3 既安全又少打字。

决策树背后的「签名说真话」原则

把决策树的三个问题再抽象一层,它们其实是同一个原则的三次应用: 让函数签名说真话。

  「签名说真话」的含义:参数类型 = 函数实际需要的最宽形状,不多承诺也不少承诺。
    承诺过宽(收 User 却只遍历)→ 强绕键检查(as keyof)来弥补谎言;
    承诺过窄(收 unknown 却当 User 用)→ 收窄链来还愿;
    承诺刚好(收 Record<string, unknown>)→ 函数体零谎言,调用方也零惊讶。

  图说:签名是说给两个听众的合同——编译器(检查用)和读代码的人(理解用)。
  两个听众都信合同,合同就得是真的。as keyof 的本质是「合同是假的,
  但我用断言把执行掰回合同」——短期可行,长期债务。

这个原则也是第 12 章「运行时依据」判据的正向版:第 12 章说「断言要有依据」, 这里说「签名要有依据」——一个管「改类型」,一个管「定类型」,共享同一个 「诚实」内核。把「签名说真话」写进团队的 code review 清单, 遍历问题、断言泛滥、谓词撒谎这三类事故会同时降温——它们都是签名撒谎的 不同症状。

5. 空对象类型 {}:「除 null 和 undefined 之外的一切」

开放对象模型的推论之二,也是本章最反直觉的一个:

const coverArtist: {} = "Guy-Manuel De Homem-Christo"; // ✅ 字符串可以!
const upcCode: {} = 724384260910;                       // ✅ 数字可以!

const submit = (homework: {}) => console.log(homework);
submit("Oh Yeah");  // ✅
submit(null);       // ❌ Argument of type 'undefined' is not assignable to '{}'

{} 不代表「空对象」,它代表「除 null 和 undefined 之外的任何东西」—— string、number、boolean、函数、symbol、带属性的对象,全部放行23。

理由正好接上开放对象:字符串和数字在 JavaScript 里也有属性(方法), 它们是「最开放的对象」;而 null/undefined 是唯一访问属性必崩的东西—— 它们才真的不符合「对象」的定义24。原书给的评价很高:「把 {} 定义成 『非 null 非 undefined』,是这个问题的一个相当优雅的解。」25

练习 12-1 就是这个类型的一次实战应用:写一个「什么都能收、就是不能收 null/undefined」的函数,答案就是一个 : {} 参数26:

const acceptAnythingExceptNullOrUndefined = (input: {}) => {};

acceptAnythingExceptNullOrUndefined("hello");        // ✅
acceptAnythingExceptNullOrUndefined(42);             // ✅
acceptAnythingExceptNullOrUndefined(true);           // ✅
acceptAnythingExceptNullOrUndefined(Symbol("foo"));  // ✅
acceptAnythingExceptNullOrUndefined({});             // ✅
acceptAnythingExceptNullOrUndefined([]);             // ✅
acceptAnythingExceptNullOrUndefined(() => {});       // ✅
acceptAnythingExceptNullOrUndefined(/foo/);          // ✅ 正则也行
acceptAnythingExceptNullOrUndefined(new Error());    // ✅

acceptAnythingExceptNullOrUndefined(null);           // @ts-expect-error ✅ 被拦
acceptAnythingExceptNullOrUndefined(undefined);      // @ts-expect-error ✅ 被拦

十一种值全过、唯二空值被拦——{} 的语义一眼看清。 (注意别和「空对象字面量的类型」混淆——const x = {} 推断出的是 {}, 但那是「暂无已知属性的对象」,可赋值性规则同上。)

{} 与 unknown 的分工:都是「全收」,区别在出口

{} 和 unknown 都「什么都能进」,日常选型容易混,分界线在出口:

  unknown:进口全收,出口全锁——想用必须先收窄(第 06 章的梯子顶)
  {}:     进口全收(除 null/undefined),出口松一半——
          可以直接访问「任何非空值都有的能力」(toString、valueOf),
          但访问具体属性仍需进一步收窄

  图说:unknown 是「完全未知的行李」,开箱前不许动;{} 是「知道不是空的行李」,
  至少可以把它当「东西」对待(打印、转字符串),但里面有什么仍不知道。

实用判据:参数的类型源头决定选谁。 源头是「网络/JSON/表单」(可能连 null 都有) → unknown,收窄链走完再干活; 源头是「框架保证非空的上下文对象」→ {} 或更精确的对象类型,直接可用。 把 {} 当 any 的替代品(「反正什么都能进」)是本章最常见的误用——它放行的 只是进口,出口的锁比 any 紧得多;误用的症状是 {} 参数体内一堆红线, 那正是出口锁在值班。

6. 主线小结:一个决定的三波推论

开放对象这一个决定,在本章连放三枪:多余属性检查只在新鲜对象上(第 3 节)、 Object.keys 只能给 string、{} 变成「非空」的别名(第 5 节)。 学到这里,第一个心智模型已经立住——「TypeScript 的对象类型是『至少包含这些属性』, 而不是『恰好只有这些属性』」。剩下两节(两个世界、函数可赋值性)是另外两根钉子。

判断(我们的,不是书里的): 「至少包含」这个语义,是 TypeScript 能覆盖 JavaScript 生态的关键设计:JSON 反序列化多出来的字段、框架注入的属性、 扩展对象——全都靠它无缝通过。它牺牲的是「形状精确」的幻觉: 你以为你的 User 恰好三个字段,运行时它可能有五个。 精确性要用别的方式买回来(第 06 章的收窄、运行时校验),而不是指望对象类型。 如果错,会错在: 对于强契约场景(API 边界、序列化协议),「至少包含」太松, 此时 schema 校验(第 06 章的 Zod 路线)才是契约的本体,类型只是它的投影—— 这个限定条件原书没有明说,我们补上。

「至少包含」的精确化:与「精确形状」的三种握手方式

「至少包含」听着像缺陷,但工程里有三种正规方式把它精确到契约级:

  方式一:fresh 检查(本章内置)——只在字面量现场生效,覆盖面窄但零成本
  方式二:判别联合(第 06 章)——用判别子把「哪些字段存在」编码进类型,
          配合 switch 的穷尽检查,达到「每个状态的形状恰好」的效果
  方式三:运行时 schema 校验(第 06 章点名的 Zod 路线)——
          值在进入应用时被验证,验证器的类型推导让「精确形状」进入类型世界

  图说:方式一管声明现场,方式二管结构互斥,方式三管运行时边界——
  三者各管一段,合起来覆盖了「精确形状」的全部需求场景。
  没有覆盖不到的场景,只有「该用哪种而没用」的错配。

这个三方式清单也是本章与第 06 章的分工说明:第 06 章教你用判别联合表达 「结构的互斥」,本章教你「对象默认是开放的、精确要用工具买」—— 两章合观,才是「对象形状精确性」的完整管理方案。跳过本章读第 06 章, 会误以为判别联合是「唯一正解」;跳过第 06 章读本章,会误以为精确性无解。 教材的章节顺序在这里又一次显出用心:先给解法(第 06 章),再揭限制(本章), 读者在知道「能做什么」之后,才被告知「默认是什么」——顺序反过来, 前者的动力会被后者的悲观稀释。

「至少包含」的边界测试:三个快问快答

把「至少包含」的语义用三个快问快答钉死,以后遇到具体代码可以直接对号:

  问 ①:{ a: 1 } 能赋给 { a: number; b: string } 吗?
    不能。后者要求的 b,前者没有——「至少包含」意味着承诺的每个属性都必须兑现。
    宽承诺 → 窄满足,和「宽进窄不行」同向。

  问 ②:{ a: 1, b: "x", c: true } 能赋给 { a: number } 吗?
    能(陈旧对象)。c 是多余属性,开放世界观下照单全收。
    但注意 fresh 二分:这是「先存变量再传」,内联字面量则会被查。

  问 ③:让「恰好只有 a」成为类型,怎么写?
    判别联合:{ kind: "x", a: number } 单成员联合——
    配合 switch 的穷尽检查,等于把「恰好」钉进了类型。
    或者用 schema 校验(三方式之三)在运行时把关。

  图说:三问答完,「至少包含」的边界、例外、强化手段各归其位——
  这条语义从此不需要再「想想」,只需要「对号」。

这组问答也是本章学习效果的自检:能不看答案答对三问,第 6 节的世界观就过关了; 答错任何一问,回到对应小节重读——问答指向的正是最容易被「差不多懂了」 盖过去的三个缝隙。

7. 类型世界与值世界:一条严格、可跨的边界

第二根钉子。TypeScript 的语法空间分成两个世界,同一行代码里可以并存:

const myNumber: number = 42;
//        值          类型  值

myNumber 和 42 是值;number 是类型。关键字还被两个世界复用:

if (typeof key === "string" && (key as keyof typeof obj)) {
//   └──── 运行时:值 ────┘      └───── 类型世界 ─────┘
}

同一个 typeof,在值的位置是运行时运算符,在类型的位置是类型提取—— 编译器按位置分辨语义27。

边界规则很硬,两个方向都试不通:

type Album = { title: string; artist: string; };

processAlbum(Album);   // ❌ Cannot find name 'Album' — 类型在值世界不存在

type T = ReturnType<processAlbum>;  // ❌ 'processAlbum' refers to a value,
                                    //    but is being used as a type here.
                                    //    Did you mean 'typeof processAlbum'?

类型不能当值用(擦除了),值不能当类型用(没提取)——报错信息还顺手教你怎么修 (typeof processAlbum)28。

跨界者一号:class

第 09 章见过:类同时活在两个世界。Song 作为类型指实例的形状; 作为值它是构造器函数本身:

const playSong = (song: Song) => { /* ... */ };

playSong(new Song("Song 1", "Artist 1")); // ✅ 传实例
playSong(Song); // ❌ Argument of type 'typeof Song' is not assignable to 'Song'

报错里的 typeof Song 就是「构造器函数这个值」的类型—— 类名当类型用 = 实例;类名当值用 = 构造器29。

跨界者二号:enum

枚举同样两界居住。AlbumStatus 作为类型指它的成员; 作为值是那个真实存在的对象:

function logAlbumStatus(status: AlbumStatus) { /* 收成员 */ }

// 想要「整个枚举对象」?typeof 取:
function logWholeEnum(status: typeof AlbumStatus) { /* 收 {NewRelease: 0, ...} */ }

原书的提醒:当类型用时,enum 指成员不是整体——想表达「整体」要 typeof30。

跨界者三号:this

类里 this 也跨界:this.playCount(值,访问属性)和 play(): this (类型,指「当前类的实例」)出现在同一个方法里31。 this 作为返回类型的回报是链式调用:

class Song {
  play(): this {
    this.playCount += 1;
    return this;
  }
}

const earworm = new Song("Mambo No. 5").play().play().play(); // 链!

原书点出一个少见的等价:play(): this 和 play(): typeof this 是一回事 ——都指向当前实例32。

this 返回类型的工程价值:多态 this(fluent 接口)

play(): this 这个小小的返回类型声明,是流式接口(fluent interface) 的类型基础:

  链式调用的类型难题:父类方法返回「父类类型」,子类实例一链式,类型就丢回父类
    class Query { where(): Query { return this; } }
    class AlbumQuery extends Query { sort(): AlbumQuery { ... } }
    new AlbumQuery().where()  ← 类型退化成 Query,sort 不见了!

  返回 this 的解法:where(): this —— 返回类型跟着「实际调用者」走
    new AlbumQuery().where()  ← 类型仍是 AlbumQuery,sort 照常补全

  图说:this 作为返回类型 = 「返回类型 = 调用者的精确类型」,
  编译器把多态性从「运行时动态分发」挪进了「类型层面的保留」。

这个模式在 2026 年的库代码里随处可见(查询构建器、测试框架的 expect 链、 Immutable 风格的集合操作)。它也是「类型即契约」的一次精采演示: 一个词的返回类型声明(this),换来子类链式调用的全程类型保留—— 成本接近零,收益贯穿整个继承体系。原书只点了这个等价关系,它的工程应用 (fluent interface)是我们补的(补充,来自通用知识);但等价关系本身 (this ≡ typeof this)是原书明写的,是理解后续应用的地基。

跨界者四号:同名类型 + 同名值

最后一种跨界是「故意重名」。一个值和一个类型同名,各住各的世界, 使用者随时切换:

export const Track = {
  play: (title: string) => { /* ... */ },
  pause: () => { /* ... */ },
  stop: () => { /* ... */ },
};

export type Track = typeof Track;   // 类型版:值的形状的镜像

使用处:track: Track 参数(类型世界),Track 实参(值世界)—— 悬停 import 会显示两条别名:一条 type、一条 const33。

这个模式的杀手锏应用,是给第 10/11 章的 as const 枚举再加一层:

export const Direction = {
  Up: "up",
  Down: "down",
  Left: "left",
  Right: "right",
} as const;

export type Direction = (typeof Direction)[keyof typeof Direction];

一个名字拿到手:值(Direction.Up)、类型("up" | "down" | "left" | "right")、 两者同步(类型从值推导)34。这就是 2026 年 TypeScript 代码库里 「枚举」事实上的标准形态——前面几章的伏笔在这里合拢。

同名双声明的设计用途:不止枚举

Track/Direction 模式的价值不止于「替代 enum」,它在三种设计场景里是正解:

  场景一:常量 + 类型的同步需求(本章主例)。
    值用于运行(遍历、查找、默认值),类型用于标注(参数、返回值)——
    同名双声明让两者共用一份定义,改常量自动改类型(第 11 章的单一事实源)。

  场景二:插件/扩展点注册表。
    const WidgetRegistry = { button: ButtonWidget, chart: ChartWidget } as const;
    type WidgetType = typeof WidgetRegistry;  // 注册表本身也是类型
    type WidgetKind = keyof WidgetRegistry;   // 键联合
    加一个新 widget = 注册表加一行,类型世界的两个联合自动跟上。

  场景三:与库的类型对接。
    库导出的是值,你的代码需要的是类型——typeof 取它的类型,
    加 satisfies 校验你自己的扩展版本(第 12 章的组合技)。

  图说:三个场景的共同点——「运行时有一份真值,类型世界需要它的投影」。
  同名双声明是这个投影的最短路径:一次定义,两个世界。

第三种场景尤其值得记住,因为它解释了为什么这个模式在 2026 年的库代码里 无处不在:schema 优先的库设计下,库导出常量/函数(值),用户用 typeof 取类型——同名双声明把「用库」和「扩展库」的接线缩短到一行。它也是第 14 章 声明文件的镜像:声明文件是「给 JavaScript 值补类型」,同名双声明是 「给 TypeScript 值同时声明类型」——方向相反,目标相同:值与类型的合一。

两个世界的「边界测试」:四个快问快答

和两个世界的分界线打交道,值得用四个快问快答把边界钉死:

  问 ①:interface Album { ... } 之后,console.log(Album) 会怎样?
    ❌ Cannot find name 'Album' —— 类型世界里没有 Album 这个值。
  问 ②:const album = {...} 之后,type T = album 会怎样?
    ❌ 'album' refers to a value... Did you mean 'typeof album'? —— 值不能当类型。
  问 ③:class Song { ... } 之后,new Song() 的类型是?
    Song —— 类是跨界者,值(Song 构造器)和类型(Song 实例)同名共存。
  问 ④:type X = typeof someValue 之后,X 是值还是类型?
    类型 —— typeof 是值世界 → 类型世界的转换器,产物是类型。

  图说:四问答完,两个世界的「门」和「跨界者」各归其位。
  答错的每一问,都对应一个具体的报错文本——报错文本本身就是边界地图,
  把它背下来,比记规则快。

这组问答和第 6 节的三问答是同族工具:用快问快答把「差不多懂了」的缝隙显影。 两组合起来,本章的两个心智模型(开放对象、两个世界)就都有了可自检的闭环—— 这也是「模型级」知识和「规则级」知识的分水岭:规则靠记,模型靠测; 能出题自测的,才是模型。

这个「模型靠测」的分水岭,也值得和第 02 章的「悬停驱动」并起来看: 悬停是模型的输入端(看见编译器眼中的世界),自测问答是模型的输出端 (验证你脑中的世界是否与之相符)——两端都通了,模型才算闭合。 只悬停不自测,容易把「看懂了」误当「掌握了」;只自测不悬停,容易把 「记熟了」误当「理解了」。本章的两组问答(第 6 节三问、本节四问), 就是输出端的标准化考卷——建议读者合上书先做一遍,再对答案; 做错的题比看懂的段落值钱十倍,因为错题指出的正是「差不多懂了」的缝隙。

两个世界的实用推论

这套世界观解释了日常报错的一半。见到 'X' refers to a value, but is being used as a type here,不必慌——报错信息里就写着药方(Did you mean 'typeof X'?); 见到 Cannot find name 'X',先想「X 是不是一个纯类型(接口/类型别名)」。 反过来,想「让一份数据同时作为值和类型被消费」时,本章给了两条正规路: 同名双声明(Track 模式)和 as const + 类型推导(Direction 模式)2834。 原书对这套机制的评价是「双份职责的功能可能非常有用,尤其当你想把 『感觉像类型』的东西在代码里多处复用时」33。

8. this 的类型用法:函数签名里的隐藏参数

this 在普通函数里也可以被标注——作为签名里的第一个「参数」:

function sellAlbum(this: {title: string; sales: number}) {
  this.sales++;
  console.log(`${this.title} has sold ${this.sales} copies.`);
}

注意两件事:调用时不用真的传 this(它指调用处的对象)35; 而且检查的时机反直觉——不在函数定义处查,在调用处查:

const album = { sellAlbum };
album.sellAlbum(); // ❌ this 上下文缺 title 和 sales

const album = { title: "Solid Air", sales: 40000, sellAlbum };
album.sellAlbum(); // ✅

报错的意思是:album 这个对象的形状,配不上 sellAlbum 声明的 this 形状36。 这个「调用处检查」的机制有个实用推论:this 参数是一种廉价的调用约束—— 你在函数签名里写下 this: {title: string; sales: number}, 等于声明「这个函数只能挂在有这些属性的对象上」,任何不满足的挂载点当场报错。 库代码里常见这种用法(jQuery 风格的插件、事件处理器), 它让「函数对宿主对象的要求」第一次变得可读可查。 补全属性后,this 在函数体内就有了完整类型。

箭头函数没有这个通道:(this: {...}) => {} 直接报错 (An arrow function cannot have a 'this' parameter)——因为箭头函数的 this 不从调用处继承,从定义处的词法作用域继承37。这条与第 09 章「箭头方法 this 绑实例」是同一个 JavaScript 语义的两面。

this 参数与「宿主契约」的设计模式

this 参数的最大价值不在工具函数,而在声明宿主契约:

  场景:一组函数,设计上必须配合某个对象使用(事件处理器、插件钩子、
        面向对象的回调)。不用 this 参数的写法,宿主要求散落在文档和注释里;
        用了 this 参数,要求进了签名:
          function onAlbumClick(this: AlbumElement, e: Event) { ... }
        挂载点不满足 AlbumElement 形状 → 定义处...不,调用处当场报错。

  收益清单:
    ① 要求可读:签名即文档,悬停可见
    ② 挂载点可检查:addEventListener 式注册处自动校验
    ③ 函数体内 this 有完整类型:补全、检查全部激活

  代价清单:
    ① 只有 function 函数可用(箭头函数无此通道)
    ② 调用处检查的报错信息对新手绕(「'this' context of type X is not assignable...」)
    ③ 与面向对象的 class 方法重叠——class 体系内该用方法而不是 this 参数

  图说:它是「结构化的宿主要求」——适合库边界和插件点,不适合类体系内部。

第 ③ 条代价值得展开:在 class 里,方法靠隐式 this(第 09 章), 再显式标 this 参数是冗余;但在「函数当一等公民传来传去」的场景 (事件注册表、插件数组),this 参数是给散落的函数补「宿主合同」的唯一通道。 选型口诀:函数被「挂」到对象上时用 this 参数,函数被「调」时用普通参数—— 挂是长期关系(要合同),调是一次性往来(普通参数就够)。

this 参数的报错阅读指南

调用处检查的报错文本对新手不友好,值得单独一份阅读指南:

  典型报错:
    The 'this' context of type '{ sellAlbum: ... }' is not assignable to
    method's 'this' of type '{ title: string; sales: number; }'.
    Property 'title' is missing in type '{ sellAlbum: ... }' ...

  三行各管一段:
    行 1(什么不匹配):this 上下文(= 调用处的对象)≠ 方法要的 this
    行 2(缺什么):对象缺 title 和 sales
    行 3(结论):所以配不上

  阅读动作:先找「Property 'x' is missing」——它告诉你往宿主对象补什么;
  补完即过。和并集报错「从底往上读」是同一个阅读纪律:
  结论行在后,明细行在中,摘要行在前。

  图说:this 参数的报错,本质是「对象形状不满足」报错的 this 包装版——
  剥掉 this 的外壳,里面就是第 04 章的老朋友(missing / required)。

这个「剥壳」技巧也是本章通用的:很多看起来新的报错,剥掉场景外壳后, 内核都是宽窄规则或 missing/required。认出内核,报错的威慑力就掉一半—— 这也是「三个模型」之外的第四条隐形主线:新报错 ≈ 旧规则的新包装。

9. 函数可赋值性:少参数合法,联合收成交集

第三根钉子,也是本章技术密度最高的一节。

规则一:实现可以忽略多余的参数

解决什么问题: 一个事件分发函数,回调有的想要 1 个参数、有的想要 3 个, 怎么给 callback 定型?

直觉解法是三个函数类型的并集——结果 callback 的参数全部变成隐式 any, 三个调用全报错38。正解出人意料地简单:

type CallbackType = (
  filename: string,
  volume: number,
  bassBoost: boolean,
) => void;

只写参数最多的那个签名。只收 1 个参数的回调合法,收 2 个的也合法—— 因为「回调会被以 3 个参数调用,而实现方忽略多余的参数」在运行时完全无害39。

反方向不成立:回调要的参数多于承诺的,报错 (Target signature provides too few arguments)40。 原书用数组的 map 做了权威例证:map 总是传 3 个参数(元素、索引、原数组), 你的回调爱用几个用几个——函数能吃下多少参数,和它必须用多少,是两回事41。

练习 12-5 是这条规则的完整演练场。事件回调要兼容 0 到 4 个参数, 直觉又是函数并集——隐式 any 全军覆没。正解依然是「只写参数最多的签名」:

type Event = "click" | "hover" | "scroll";

type CallbackType = (
  event: Event,
  x: number,
  y: number,
  screenId: number,
) => void;

const listenToEvent = (callback: CallbackType) => { /* ... */ };

listenToEvent(() => {});            // ✅ 0 参实现
listenToEvent((event) => { ... });  // ✅ 1 参
listenToEvent((event, x, y, screenId) => { ... }); // ✅ 4 参

所有调用通过,且每个回调里的参数都有精确类型(Event、number……), 不是 any——因为实现参数少于声明是子类型关系,不是类型错误42。

规则二:函数联合的参数是交集

解决什么问题: 一个对象里存了几个「各收不同形状」的函数, 按键取出后,该用什么类型调用它?

const formatterFunctions = {
  title:       (album: {title: string}) => `Title: ${album.title}`,
  artist:      (album: {artist: string}) => `Artist: ${album.artist}`,
  releaseYear: (album: {releaseYear: number}) => `Release Year: ${album.releaseYear}`,
};

const getAlbumInfo = (album: ???, key: keyof typeof formatterFunctions) => {
  const functionToCall = formatterFunctions[key];
  return functionToCall(album);
};

悬停 functionToCall:三个函数类型的并集。直觉上 album 该喂 「三个形状的并集」——报错:

Argument of type '{title} | {artist} | {releaseYear}' is not assignable to
parameter of type '{title} & {artist} & {releaseYear}'.

并集的函数,参数收成交集! 逻辑其实直白:调用这个并集时,编译器不知道 取出来的是哪个函数——argument 必须同时满足每一个可能的函数, 也就是交齐所有属性43。修法:

album: {title: string} & {artist: string} & {releaseYear: number}
// 等价于
album: {title: string; artist: string; releaseYear: number}

把推导链走完整:

  formatterFunctions[key] 的键 → "title" | "artist" | "releaseYear"
  → 取出的值是三个函数的并集
  → 调用并集 = 必须喂「每个函数都能接受」的参数
  → 参数 = {title} ∩ {artist} ∩ {releaseYear}
  → = {title: string; artist: string; releaseYear: number}

悬停确认:传修复后的 album,getAlbumInfo 返回类型精确是 string。 而交叉写法 A & B & C 与合并对象类型等价(第 07 章的交叉即属性合并)—— 所以 & 串可以直接塌缩成一个普通对象类型44。

44 练习 12-6 是双函数版,结论相同:loggers 数组的元素是函数并集, logAll 的 obj 参数取交集({id: string} & {name: string})或合并对象类型45。

规则二的「为什么」:调用方视角的强制推论

「并集收成交集」初读反直觉,但从调用方的视角推,它是唯一诚实的答案:

  你手里握着 functionToCall: F1 | F2 | F3(三个函数的并集)。
  你要调用它,传一个参数 input。

  问题:input 该满足哪个函数的参数要求?
    你此刻不知道抽到的是哪个函数 —— 编译器也不知道,运行时才知道。
    所以 input 必须满足「无论抽到哪个都能用」:
      满足 F1 的要求 ∧ 满足 F2 的要求 ∧ 满足 F3 的要求
    ∧(且)在类型里就是交叉 &。

  图说:并集是「三个里抽一个」,交集是「三个的要求全满足」——
  从「不确定」到「全满足」的这一步,是随机性倒逼出来的必然。

这个推导也解释了为什么「宽进窄出」的直觉在这里失效: 函数参数位置是「宽进」(来者不拒),但当你不知道函数是谁时, 参数必须满足最挑剔的那个要求——不确定性把「宽」抬成了「交」。 同一个「参数宽」规则,在「函数已知」时宽松,在「函数未知」时收紧—— 规则没变,变的是你对函数的了解程度。这也是全章反复出现的模式: 类型规则的松紧,跟着「编译器知道多少」走(收窄、fresh/stale、 联合收成交集,全是这一条的不同投影)。

「编译器知道多少」的四档刻度

把这个反复出现的模式显式化,它其实是四档刻度——日常判断任何类型行为的 松紧,都可以先在刻度上定位:

  第一档:一无所知(unknown / 外部输入)
    行为:全部收紧。什么都别想用,先交证据(收窄链)。
  第二档:知道一部分(并集 / 判别联合)
    行为:部分放宽。公共能力随便用,独有能力要验证(收窄)。
  第三档:知道形状但怕漂移(陈旧对象 / 联合函数)
    行为:形状可用,细节存疑。键给宽的(string[]),参数收成交集。
  第四档:亲手写的现场(新鲜字面量 / 标注返回类型)
    行为:全部收紧的检查都在这里——因为「此刻写下的」最不可能撒谎。

  图说:四档从「无知」到「亲写」,检查的松紧呈 U 型——
  两端都紧,中间松。紧的两端,防的是「什么都不知道」和「你以为你知道」;
  松的中间,奖励的是「已经交出部分证据」。

这个 U 型刻度是本章所有机制的统一定价模型:fresh 检查之所以只在字面量现场, 是因为那是第四档;Object.keys 之所以给 string[],是因为那是第三档; unknown 之所以全锁,是因为那是第一档。把刻度记住,遇到任何新的类型行为, 先问「它把我放在哪一档」——答案几乎自动给出行为的松紧解释。这也是 「模型级」知识的又一项用途:不是背规则,而是拿到定价的尺子。

推到极限:参数交集为空时,得到 never

练习 12-7 把这条规则推到了危险的边界。objOfFunctions 的键是 string / number / boolean,各配一个同类型参数的函数:

const objOfFunctions = {
  string:  (input: string) => input.toUpperCase(),
  number:  (input: number) => input.toFixed(2),
  boolean: (input: boolean) => (input ? "true" : "false"),
};

const format = (input: string | number | boolean) => {
  const inputType = typeof input as "string" | "number" | "boolean";
  const formatter = objOfFunctions[inputType];
  return formatter(input); // ❌ 'string | number | boolean' is not assignable to 'never'
};

formatter 是三个函数的并集 → 参数收成交集 → string & number & boolean = 不可能有值同时是这三样 → never。悬停 formatter,签名是 (input: never) => string46。

两种修法:

// ① 断言到 never(危险但知道自己在做什么)
return formatter(input as never);
// 注:as any 不行!any 赋给 never 依然报错——any 不是万物的通行证

// ② 放弃联合,运行时收窄后分别调用(啰嗦但诚实)
if (typeof input === "string") return objOfFunctions.string(input);
else if (typeof input === "number") return objOfFunctions.number(input);
else return objOfFunctions.boolean(input);

47 原书给了两个有价值的注脚:「as never 有点不安全,但运行时行为你知道 input 总是三者之一」;以及那个冷知识——any 不是 never 的通行证 (Argument of type 'any' is not assignable to parameter of type 'never'), 这是第 06 章「any 两头占」规则的一个具体显形48。

「any 不是 never 的通行证」的完整解释

这个冷知识值得多花两分钟,因为它是理解 any/never/unknown 三者关系的钥匙:

  类型系统的「可赋值」规则,对三个特殊类型各有说法:
    unknown:什么都能进(顶端),出去必须收窄 —— 「进易出难」
    never:  什么都能出(底端),但什么都不能进 —— 「出易进难」
    any:    两边都放行 —— 但它有个例外:never 不接它

  为什么 any 进不了 never?因为 never 的参数位置含义是
  「调用方保证这里不存在任何值」,而 any 的含义是「这里可能是任何东西」——
  「任何东西」和「不存在任何东西」直接矛盾。any 的豁免权在 never 面前失效,
  恰恰是类型系统少有的「连 any 也讲规矩」的时刻。

  图说:unknown 和 never 是梯子两端的守规矩者,any 是翻栏杆的——
  但 never 这道墙的栏杆连着电,any 翻不过去。这是全类型系统里 any 唯一翻不过的墙。

这个例外在实践中有个直接用途:验证一个值是不是 never。 const test: never = someValue; —— 如果 someValue 是任何真实类型(包括 any), 都报错;只有 never 本身能过。第 16 章的穷尽性检查用的正是这个性质: assertNever(x: never) 的参数位置是「类型系统的测谎仪」,any 都骗不过它。

「函数可赋值性」的完整图景:三规则一例外

把第 9 节的两条规则和本章其他函数相关行为合起来,函数可赋值性的完整图景是 「三规则一例外」,值得收成总表:

  规则一(少参数合法):实现参数 ≤ 声明参数,恒安全
    依据:多余参数被忽略,在运行时无害
  规则二(联合收成交集):函数并集的参数 = 各成员参数的交集
    依据:调用方不知道会抽到哪个函数,必须全都喂得动
  规则三(never 参数):交集为空时参数塌缩成 never
    依据:没有任何值能同时满足互斥的参数类型
  例外(any 不过 never):any 不可赋给 never
    依据:「任何东西」与「不可能有东西」矛盾

  图说:三规则一例外覆盖了函数在类型系统里的全部「反直觉」行为。
  它们的共同根源是「参数位置的类型方向与直觉相反」(逆变)——
  返回值求精确(越窄越好),参数求包容(越宽越好)。

「参数宽、返回值窄」这个方向感,和第 06 章的「窄进宽出」、第 07 章交叉的 「与收窄或放宽」是同一套坐标系的三个投影。函数签名 = 进口宽 + 出口窄—— 记住这个不对称,三规则都能现场推出来:规则一是「进口宽」的推论(实现少收参数 = 进口更宽,当然安全),规则二是「多个不确定的进口取最宽的要求」的推论, 规则三是「要求无限叠加 → 不可能」。例外则是 any 的特权的边界。 坐标系记住,规则不必背。

三道压轴练习的合奏:12-5、12-6、12-7 各补一块拼图

第 9 节的机制在三道练习里各被考了一次,合起来是完整的「函数类型应用题」谱系:

  12-5(CallbackType 全参数版):考规则一的正向应用
    直觉(函数并集)→ 隐式 any 灾难 → 正解(只写参数最多的签名)
    收获:「少参数合法」不只是宽容,是写回调契约的正确姿势

  12-6(loggers 交集):考规则二的温和形态
    两个函数的参数形状「兼容」({id} 和 {name} 可以共存)
    → 交集 {id, name} 是普通对象类型,一切顺遂
    收获:联合收成交集在「参数兼容」时毫不惊悚——惊悚只在 12-7 的互斥形态

  12-7(objOfFunctions  never 化):考规则二+规则三的极限形态
    三个函数的参数互斥(string/number/boolean)
    → 交集为空 → never → as never 强绕 或 运行时收窄二选一
    收获:never 不是终点,是「这条路走到头了」的信号;
    见到参数 never 化,正确反应是回头改设计(运行时收窄),不是找更强的断言

  图说:三道练习难度递增,但考的是同一组规则的不同温度——
  常温(12-5)、温热(12-6)、沸腾(12-7)。全组做完,
  「函数联合」这个形态的全部行为都有了手感。

这组练习也是第 16/17 章的预告:12-7 的「运行时收窄后分别调用」, 用条件类型的分配性可以自动完成(第 16 章);把「哪些函数参数兼容」的判断 封装成谓词,是第 17 章类型谓词的标准用例。原书在本章用手写代码演示现象, 后面两章给出「让类型系统替你写这些代码」的工具—— 先现象、后工具的编排,在全书中反复出现。

10. 这一章在全书链条中的位置

本章是全书前十一章的「答疑课」:evolving any 是第 04/08 章 any 与收窄的合流; 新鲜/陈旧是第 07 章「结构化的宽容」的精确化;两个世界是第 09 章 「类名当类型用」的泛化;函数可赋值性是第 05 章函数类型的隐藏规则。 原书小结的说法值得收进笔记:「这些特性不是怪癖,而是 TypeScript 设计决定的 可预期推论」——判断「怪不怪」的标准,是它有没有设计理由,而不是它合不合 第一印象49。下一章(第 14 章)进入工程底座:模块、声明文件、tsconfig—— 两个世界的分界线在 .d.ts 文件里会被画得更加正式。

本章的「答疑清单」:十一个伏笔的回收表

把「答疑课」的角色坐实,值得把本章回收的伏笔列成一张表——读者将来重读 前面任何一章遇到「等等,为什么?」的时刻,可以回这里查答案:

前文留下的疑问 本章的回收位置 给出的解释
第 04 章:any 为什么不彻底? §2 evolving any any 有「进化」变体,不是铁板一块
第 04 章:可选参数拼错怎么没报错? §3 新鲜/陈旧 陈旧对象不查多余属性,拼错=多余键
第 05 章:回调参数为什么能少不能多? §9 规则一 实现忽略多余参数在运行时无害
第 06 章:Object.keys 为什么只给 string[]? §4 松散键 开放对象 ⇒ 键集不可枚举
第 06 章:never 为什么什么槽位都能放? §9 推到极限 空集是任何集合的子集
第 07 章:工具类型为什么只一层? §5 空对象型 「只一层」和开放对象是同一个保守姿态
第 08 章:as const 的字面量推断为什么保留? §7 两个世界 类型从值推导,值的世界在先
第 09 章:类名为什么能当类型用? §7 跨界者 class 是类型与值两个世界的合法居民
第 10 章:namespace 为什么能装类型? §7 跨界者 namespace 也是双世界居民
第 11 章:keyof 数组为什么是方法键? §4 / §9 开放世界的键集哲学
第 12 章:satisfies 为什么能保住推断? §6 主线小结 检查不改写,是「至少包含」的精致应用

这张表是本章作为「答疑课」的完整课表:十一个伏笔,每个都在本章某节找到了 「设计理由」级别的答案。重读前面章节时,建议把这张表放在手边——它比 任何复习笔记都高效,因为它是从「问题」而不是从「章节」组织的。

11. 作者的判断与证据(分开标)

书里有据的:

  • evolving any 的「高级收窄」定性——语言机制,正误对照在案23。
  • 新鲜/陈旧二分及其理由(别处可能用到多余属性)——原书明写678。
  • 可选参数拼写场景的「静默错误」风险——真实陷阱,修法在案9。
  • 与 Flow 的开放/封闭对比及 TypeScript 立场——原书明确表态「more sense」1213。
  • Object.keys 的 string[] 与「这是保护」的论证——含运行时崩溃反例1517。
  • {} 的「非 null 非 undefined」语义及优雅性评价——含完整可赋值性实验2325。
  • 两个世界的边界规则、四个跨界者(class/enum/this/同名双声明)——机制与报错原文在案28293034。
  • this 参数的检查时机(调用处)、箭头函数无 this 参数——机制描述含报错原文3637。
  • 函数参数「少实现合法、多实现报错」及 map 例证——含报错原文394041。
  • 函数联合收成交集、never 化、as never 与 as any 的区别——含完整推导链434648。

原书没说的(我们补):

  • 「三个模型」的诊断流程(悬停 → 查模型 → 搜 issue)——我们的补充,判断块已展开。
  • 「诚实胜过方便」的三连注脚(开放对象 / string[] 键 / 少参数合法)是同一条 设计价值观的三次贯彻——我们的归纳,原书给了事实但没有命名这条贯穿线。
  • Object.entries 作为遍历的第 ⑤ 选项——补充(来自通用知识),原书只列了四种。
  • 多态 this 在流式接口中的应用——补充(来自通用知识),原书只给了 this ≡ typeof this 的等价关系。
  • 「模型靠测、规则靠记」的分野与两组快问快答——我们的教学处理,原书未提供自测题。
  • evolving any 的「改写次数预算」(≤3 次用进化,超过用显式并集)——我们的经验值,原书未量化。

  • evolving any 与「历史操作记录 vs 契约」的工程取舍——判断块中已展开(我们的补充)。

  • 「函数联合收成交集」与数学上的「余积/积」对偶——补充(来自通用知识): 在类型论里这叫函数类型的逆变(contravariance),参数位置类型上下颠倒; TypeScript 在这里遵守的是标准的函数子类型规则。
  • never 作为「空交集」的计算结果,与第 06 章「never = 不可能」的呼应是原书暗线, 我们把它挑明了。

判断(我们的,不是书里的): 本章对编程语言研究的价值,在于它展示了一次 「为宿主语言服务」的类型设计如何系统性地放弃理想化: 开放对象放弃了形状的封闭性(换与 JS 生态的兼容), string[] 放弃了键的精确性(换类型系统的可靠性), 函数参数的「少实现合法」放弃了签名的一一对应(换回调生态的可用性)。 三个放弃是同一个姿态的三种姿态:类型系统的职责是「在真实的 JavaScript 上 给出有用的保证」,而不是「给出它理论上能给的最强保证」。 这个设计哲学可以解释 TypeScript 后来几乎所有争议决定的走向(包括第 10 章 三个遗产特性的封存)——它比任何单个机制都值得记住。 如果错,会错在: 「有用而非最强」的边界由谁来画是有争议的: 函数参数的双变(bivariance)方法检查就是历史上被批评为「过度妥协」的一例 (strictFunctionTypes 开关只修了一半)。把 TypeScript 的每个放弃都合理化, 同样是一种事后偏见——本章的辩护有些我们可以接受,有些(如方法参数的双变) 属于历史遗留。

判断(我们的,不是书里的): 本章最实用的收获不是任何单个机制, 而是那套「先问设计理由,再下怪癖判断」的问诊流程——遇到任何一个 「不合直觉」的类型行为,按三步走:① 悬停看现状;② 回本章查它是不是 三个模型(开放对象 / 两个世界 / 函数可赋值性)的推论;③ 都不是, 再去搜 issue。三步走的价值在于把「怪」从情绪词变成定位词: 「这很怪」到此为止没有信息量;「这是开放对象的推论」直接指向解法。 这套问诊流程是全书工具箱里「迁移性最强」的一件——它适用于任何语言、 任何版本、任何「为什么这样设计」的时刻,比本章的任何具体规则都活得久。 如果错,会错在: 有些「怪」真的是 bug 或设计失误(类型系统的实现 并非处处有深思熟虑),强制寻找「设计理由」可能把 bug 也合理化。 分辨标准:设计理由通常能在官方文档或设计注释里找到出处,bug 只能找到 issue 里的「confirmed」标签——问诊流程的第 ③ 步不能省。

12. 边界与局限

  • 新鲜/陈旧的判定是位置性的,有时出人意料(函数返回值算陈旧)—— 练习 12-3 的 map 回调返回值就是这样漏检的;防御法则是原书给的: 「可能含多余属性的变量,显式声明类型;函数,显式声明返回类型」50。
  • as keyof 强绕键检查在「对象声明类型窄于实际形状」时会制造运行时崩溃 (第 4 节的反例)——不是 TypeScript 刁难,是它替你把运行时风险提前标出来了。
  • this 参数只适用于 function 函数;方法简写(shorthand method)里的 this 推断规则更隐晦,本章未展开。
  • as never 双断言是本章最危险的单招:它把「参数检查整个关闭」, 只在「联合的全部成员的参数在运行时保证互斥且已被外部收窄」时才勉强正当。
  • 函数联合收成交集的推导在本章是「现象级」教学;它的类型论根基(逆变) 要到第 16 章条件类型才会再正面相遇。
  • 「三个模型」不是万能钥匙:有些行为(如 fresh/stale 的精确触发条件、 evolving any 的数组推断细节)是多条规则叠加的结果,套用一个模型会得到 「差不多对」的解释,精确行为仍以悬停和实验为准——模型是地图,不是领土。
  • 本章机制与 strict 家族的关系:本章的宽松行为大多在 strict 关闭时 进一步放宽(如第 05 章的隐式 any 会大面积回归),「怪癖浓度」随 strict 等级 反向变化——第 15 章的配置选择,决定了你在日常代码里会见到的怪癖总量。
  • as never、as any、@ts-ignore 在「关闭检查」这个维度上是同一个家族, 只是粒度不同(表达式级 / 行级);选择粒度最小的那个,是第 12 章纪律 在本章的具体应用。
  • 「断言式遍历」(as keyof)与其他强绕手段共享同一个风险模型: 签名说真话,强绕才安全;签名说谎(接口窄于实际形状),强绕就是给崩溃开路。 这把第 12 章「运行时依据」判据扩展到了类型操作的所有场景。

13. 可带走的

  1. evolving any:未标注的 let x; 和空数组 [] 的类型随赋值/push 进化; 是窄道,不是大道。
  2. 新鲜对象(字面量现场)查多余属性,陈旧对象(变量/返回值)不查—— 拼错的可选参数名就是这么漏网的。
  3. 函数比较(无返回类型注解的回调)不查多余属性——内联回调要检查就标返回类型或 satisfies。
  4. TypeScript 对象 = 「至少这些属性」;Flow 是「恰好」。前者的代价是键检查的保守。
  5. Object.keys 返回 string[] 是保护——用 as keyof typeof 强绕之前,想想运行时崩溃的反例。
  6. {} = 除 null/undefined 外的任何东西;「什么都能收、不收空」的参数就写 {}。
  7. 类型世界与值世界:类型不能当值,值不能当类型(报错会教你 typeof);class、enum、this、同名双声明是四个合法跨界者。
  8. const X = {...} as const; type X = typeof X 是值与类型同名的标准形态——枚举的 2026 年版答案。
  9. 函数实现可以少参数、不能多参数;回调签名只写参数最多的版本。
  10. 函数并集的参数收成交集;不兼容时交集成 never;as never 能过、as any 不能过。
  11. this 可以在函数签名里标注成隐藏参数(调用处检查);箭头函数没有这个通道。
  12. 判断「怪不怪」的标准是它有没有设计理由——本章每个怪癖都有。
  13. 让 @ts-expect-error 重新生效的三姿势:注解、satisfies、内联——任选,原理都是「变新鲜」。
  14. 遍历键值问题优先考虑 Object.values 和放宽参数类型,其次才是 as keyof。
  15. 「有用而非最强」是 TypeScript 的系统性姿态——三个放弃共享同一个理由。
  16. 遇到「不合直觉」的类型行为,用三步问诊:悬停看现状 → 查三个模型 → 都不是再搜 issue; 「怪」从情绪词变成定位词,是本章最重要的思维迁移。
  17. 函数并集收成交集、参数逆变、never 空集——三条规则共享同一个数学事实: 空集是任何集合的子集,函数参数位置类型上下颠倒。记住一个,推得三个。
  18. 「诊断签名是否说真话」是 as keyof、类型谓词、断言函数共同的安全前提—— 签名说谎时,一切强绕都在给运行时崩溃开路。
  19. 类型规则的松紧跟着「编译器知道多少」走——四档刻度(无知/部分/漂移/亲写) 是全部机制的统一定价模型;遇到新行为先问「它在哪一档」。
  20. evolving any 与拓宽方向相反:前者「槽随值变」,后者「槽已定换值」; 改写次数 ≤3 用进化,超过就显式标注(复杂度预算思维)。
  21. 「模型靠测、规则靠记」——两组快问快答(第 6 节三问、第 7 节四问)是 本章两个心智模型的标准考卷;能出题自测的才算掌握。

这 21 条按「机制—判断—迁移」三层分组:1–11 是机制层(本章的具体规则), 12–15 是判断层(如何评估一个行为是不是怪癖),16–21 是迁移层(把本章的 思维方式带出门)。三层里,判断层和迁移层的寿命最长——机制会被语言版本 修订,判断方法不会。这也是本章在全书的独特定位:它不教最多的工具, 它教最耐用的排障哲学。

这个定位也值得和第 02 章的「三板斧」对照着收个尾:第 02 章教你的是 「工具层的排障」(悬停、补全、读报错),本章教的是「设计层的排障」 (问诊、建模、找光源)——两把刷子合起来,才算完整的排障装备: 工具层让你快速看到现状,设计层让你解释现状从何而来。 只学工具层,遇到「为什么」会卡住;只学设计层,遇到「是什么」会空转。 原书把第 02 章放在开头、本章放在中段的用意,到此完整显影: 先给显微镜,再给地质图——两者都是看同一块岩石,一个看纹理,一个看成因。

最后给十七章内容一句总纲:「怪癖是设计决定的影子——找到光源,影子就有了形状。」 三个模型(开放对象、两个世界、函数可赋值性)就是那三盏灯;本章的全部工作, 是把读者从「被影子吓到」带到「随手开灯」。开灯的姿势一旦学会, 任何语言、任何版本的「怪」都只是尚未定位的设计决定——这是本章离开 TypeScript 仍然有效的部分,也是全书「类型驱动思维」主题的最后一次完整演练。

本章的一句话版

按拆解惯例,收一句给赶时间的读者:「新鲜查、陈旧放;键宽值窄对象开放; 类型值两世界,函数参数反着来。」 三个分句各管本章的一根支柱 (fresh/stale、开放对象、两个世界与函数可赋值性);展开、边界、例外都在 正文各节,但记住这三句,本章的骨架就立住了。它们也是后续四章反复引用的 公共词汇——第 14 章的 .d.ts 是两个世界的正式化,第 16 章的条件类型是 「参数反着来」的类型级版本,第 17 章的谓词是新鲜检查的可复用函数版。 本章的三句版,值得和其他章的三句版并排贴在墙上,作为全书「怪癖防御」的 第一道警戒线。

本章的「课后作业」:三盏灯的实战考核

和其他章一样,本章也留一份可立即动手的作业,作为三盏灯的实战考核:

  考核一(开放对象):找一个你项目里的接口,确认它的消费方式——
    有没有地方依赖「恰好只有这些属性」?有的话,那里就是 fresh 检查
    或 schema 校验该站岗的位置。
  考核二(两个世界):找一个库导出的常量,用 typeof 取它的类型,
    再 keyof 取键联合——体验一次「值的世界 → 类型的世界」的完整穿越。
  考核三(函数可赋值性):写一个收回调的参数,故意只给回调用一半参数,
    观察类型系统的反应;再写一个函数并集,悬停它的参数,看交集是怎么收的。

  图说:三个考核各对应一盏灯;每个耗时不超过十分钟,
  做完三盏灯就从「看懂」变成了「用过」——本章的全部目的。

作业做完,本章的拆解也到了真正的尾声。十三章的正文、判断、地图三层齐备, 与全书其他十六章完全同构;读者带着三盏灯和一句话版,已经可以随时出发—— 第 14 章的工程底座(模块、声明文件、tsconfig)在等着把这三盏灯装进 真实的项目结构里。

14. 原文地图

本章地图覆盖原书第 12 章(The Evolving any Type / The Weird Parts)的全部小节与七道练习。 新增条目对应加厚内容:evolving any 与拓宽的对照、开放对象的世界观代价收益清单、 精确化三方式、遍历决策树、{} 与 unknown 的分工、同名双声明的三场景、 this 参数与宿主契约、多态 this 与流式接口、any/never/unknown 关系图、 函数可赋值性三规则一例外、十一个伏笔的回收表——均可按行号 + 搜词两步回核。

主题 原书位置 原文位置
本章的定位(设计决定) The Evolving any Type text/28-fm-the-evolving-any-type.txt:6(搜「unusual and lesser-known」)
evolving any 的基本行为 同上 text/28-fm-the-evolving-any-type.txt:23(搜「659457206512」)
「高级形式的收窄」 同上 text/28-fm-the-evolving-any-type.txt:38(搜「advanced form of narrowing」)
evolving any 数组版 同上 text/28-fm-the-evolving-any-type.txt:42(搜「evolvingArray」)
陈旧对象放行(rubberSoul) 同上 text/28-fm-the-evolving-any-type.txt:74(搜「No error!」)
内联对象报错 同上 text/28-fm-the-evolving-any-type.txt:87(搜「may only specify known properties」)
新鲜/陈旧的定义 同上 text/28-fm-the-evolving-any-type.txt:210(搜「freshness」)
拼错可选参数的陷阱 同上 text/28-fm-the-evolving-any-type.txt:109(搜「timeOut: 1000」)
函数比较不查多余属性 同上 text/28-fm-the-evolving-any-type.txt:147(搜「This is strange」)
加返回类型注解修复 同上 text/28-fm-the-evolving-any-type.txt:156(搜「(album): Album」)
开放 vs 封闭(Flow 对照) 同上 text/28-fm-the-evolving-any-type.txt:173(搜「closed by default」)
「TypeScript 的选择更合理」 同上 text/28-fm-the-evolving-any-type.txt:191(搜「makes more sense」)
Object.keys 返回 string[] 同上 text/28-fm-the-evolving-any-type.txt:256(搜「string[]」)
索引访问报错 同上 text/28-fm-the-evolving-any-type.txt:265(搜「implicitly has an 'any' type」)
「这是保护」与运行时崩溃反例 同上 text/28-fm-the-evolving-any-type.txt:301(搜「not a function」)
{} 的可赋值性实验 同上 text/28-fm-the-evolving-any-type.txt:323(搜「coverArtist」)
{} = 非 null 非 undefined 同上 text/28-fm-the-evolving-any-type.txt:317(搜「isn't null or undefined」)
「优雅的解」评价 同上 text/28-fm-the-evolving-any-type.txt:348(搜「elegant solution」)
两个世界同排示例 同上 text/28-fm-the-evolving-any-type.txt:352(搜「two syntactical spaces」)
边界报错(Cannot find name) 同上 text/28-fm-the-evolving-any-type.txt:377(搜「Cannot find name」)
class 跨界(typeof Song) 同上 text/28-fm-the-evolving-any-type.txt:421(搜「typeof Song」)
enum 跨界 同上 text/28-fm-the-evolving-any-type.txt:461(搜「members of the enum」)
this 跨界与链式调用 同上 text/28-fm-the-evolving-any-type.txt:486(搜「Mambo No. 5」)
同名类型+值(Track) 同上 text/28-fm-the-evolving-any-type.txt:506(搜「export const Track」)
Direction 枚举模式 同上 text/28-fm-the-evolving-any-type.txt:565(搜「export const Direction」)
this 参数与调用处检查 同上 text/28-fm-the-evolving-any-type.txt:617(搜「this: {title」)
箭头函数无 this 参数 同上 text/28-fm-the-evolving-any-type.txt:660(搜「cannot have a 'this' parameter」)
函数并集参数的隐式 any 同上 text/28-fm-the-evolving-any-type.txt:710(搜「implicitly has an 'any' type」)
只写最多参数的签名 同上 text/28-fm-the-evolving-any-type.txt:714(搜「CallbackType = (」)
「少用参数合法」与 map 例证 同上 text/28-fm-the-evolving-any-type.txt:753(搜「doesn’t mean it has to use them all」)
函数联合收成交集 同上 text/28-fm-the-evolving-any-type.txt:806(搜「intersection of them」)
never 化与 as never 同上 text/28-fm-the-evolving-any-type.txt:1318(搜「not assignable to parameter of type 'never'」)、:1336(搜「as never」)
any 不是 never 的通行证 同上 text/28-fm-the-evolving-any-type.txt:1344(搜「any' is not assignable」)
收窄后分别调用(正解二) 同上 text/28-fm-the-evolving-any-type.txt:1346(搜「narrowing down the type」)
「希望标题起错了」结语 同上 text/28-fm-the-evolving-any-type.txt:1372(搜「inevitable decisions」)
evolving any 与拓宽的对照 The Evolving any Type text/28-fm-the-evolving-any-type.txt:38(搜「advanced form of narrowing」)
开放对象的代价收益清单 同上 text/28-fm-the-evolving-any-type.txt:171(搜「open」) · text/28-fm-the-evolving-any-type.txt:191(搜「makes more sense」)
misspelled 的事故树 同上 text/28-fm-the-evolving-any-type.txt:101(搜「misspell」) · text/28-fm-the-evolving-any-type.txt:114(搜「only way to source」)
「至少包含」的快问快答 同上 text/28-fm-the-evolving-any-type.txt:317(搜「isn't null or undefined」)
this ≡ typeof this 与流式接口 同上 text/28-fm-the-evolving-any-type.txt:487(搜「same thing」)
同名双声明(Direction 模式) 同上 text/28-fm-the-evolving-any-type.txt:572(搜「export type Direction」)
三道压轴练习(12-5/6/7) 同上 text/28-fm-the-evolving-any-type.txt:1205(搜「CallbackType = (」) · text/28-fm-the-evolving-any-type.txt:1318(搜「not assignable to parameter of type 'never'」)
「编译器知道多少」的四档刻度 The Evolving any Type text/28-fm-the-evolving-any-type.txt:305(搜「safest thing」) · text/28-fm-the-evolving-any-type.txt:806(搜「intersection of them」)
函数可赋值性三规则一例外 同上 text/28-fm-the-evolving-any-type.txt:741(搜「too few arguments」) · text/28-fm-the-evolving-any-type.txt:1344(搜「any' is not assignable」)
this 参数的报错与宿主契约 同上 text/28-fm-the-evolving-any-type.txt:634(搜「'this' context」)
「签名说真话」与遍历决策树 同上 text/28-fm-the-evolving-any-type.txt:1131(搜「Record<string, unknown>」) · text/28-fm-the-evolving-any-type.txt:1149(搜「two main choices」)

本章拆解到此完整。原书第 12 章(The Weird Parts)的全部内容——evolving any、 fresh/stale、开放对象、Object.keys、空对象类型、两个世界、this、函数可赋值性, 以及七道练习——均已落到本章的某一节,并配上了回核路径。第十三章是全书拆解的 「答疑课」收官,它的三盏灯(三个心智模型)会一路亮到第 17 章。 本章的全部新增内容——对照表、决策树、四档刻度、三规则一例外、两组快问快答、 三盏灯作业——共同构成一份「怪癖防御手册」,可随时翻阅,不必从头再读。 读者与手册的缘分从这本书开始,却不会随这本书结束——它会被带到下一个项目、 下一门语言、下一次「这怎么可能是对的」的时刻。第十三章拆解,正式收工—— 收工前最后一次核对:三盏灯亮着,手册在手,怪癖无处遁形。全书拆解,继续—— 下一章(第 14 章,模块与声明文件)见,那里的「怪癖」会换一种形态出现: 不是行为的怪,而是历史的怪。两种怪,同一种问诊流程——本章的三盏灯, 对历史的怪同样有效(模块的怪癖,多半也是某个设计决定在地形上的投影)。 第十三章拆解全部完成,我们第 14 章拆解再见——再见时,愿三盏灯常亮, 怪癖皆成旧识,类型尽是朋友。第十三章,完。完得干净利落,如所有好章的收尾—— 而下一章的怪,已经在敲窗了。收工,去开门——门后是第 14 章, 模块、声明文件与 tsconfig 的工程底座。第十三章,正式谢幕——谢幕,致礼,再见。 三盏灯留在门口,照亮后来者的路。路已亮,灯长明,怪癖从此有了名字——第十三章终。


  1. 出处:「The Evolving any Type」第 1360–1372 段(text/28-fm-the-evolving-any-type.txt:1372,搜「inevitable decisions」)。 ↩

  2. 出处:「The Evolving any Type」第 12–38 段(text/28-fm-the-evolving-any-type.txt:36,搜「does not exist on type 'number'」)。 ↩↩↩

  3. 出处:「The Evolving any Type」第 38 段(text/28-fm-the-evolving-any-type.txt:38,搜「advanced form of narrowing」)。 ↩↩

  4. 出处:「The Evolving any Type」第 40–49 段(text/28-fm-the-evolving-any-type.txt:47,搜「{easy: boolean}」)。 ↩

  5. 出处:「The Evolving any Type」第 53–76 段(text/28-fm-the-evolving-any-type.txt:74,搜「No error!」)。 ↩↩

  6. 出处:「The Evolving any Type」第 193–228 段(text/28-fm-the-evolving-any-type.txt:228,搜「warn you about any excess properties」)。 ↩↩↩

  7. 出处:「The Evolving any Type」第 230–240 段(text/28-fm-the-evolving-any-type.txt:240,搜「not checked for excess properties」)。 ↩↩

  8. 出处:「The Evolving any Type」第 91–99 段(text/28-fm-the-evolving-any-type.txt:95,搜「excess property is needed」)。 ↩↩

  9. 出处:「The Evolving any Type」第 101–121 段(text/28-fm-the-evolving-any-type.txt:118,搜「timeOut」)。 ↩↩

  10. 出处:「The Evolving any Type」第 123–150 段(text/28-fm-the-evolving-any-type.txt:150,搜「without TypeScript even knowing」)。 ↩

  11. 出处:「The Evolving any Type」第 152–167 段(text/28-fm-the-evolving-any-type.txt:159,搜「strange Property」)。 ↩

  12. 出处:「The Evolving any Type」第 169–191 段(text/28-fm-the-evolving-any-type.txt:173,搜「closed by default」)。 ↩↩

  13. 出处:「The Evolving any Type」第 191 段(text/28-fm-the-evolving-any-type.txt:191,搜「makes more sense」)。 ↩↩

  14. 出处:「The Evolving any Type」第 242 段(text/28-fm-the-evolving-any-type.txt:242,搜「best of both worlds」)。 ↩

  15. 出处:「The Evolving any Type」第 244–274 段(text/28-fm-the-evolving-any-type.txt:272,搜「returns string[]」)。 ↩↩

  16. 出处:「The Evolving any Type」第 275–279 段(text/28-fm-the-evolving-any-type.txt:278,搜「red squiggly line under key」)。 ↩

  17. 出处:「The Evolving any Type」第 281–305 段(text/28-fm-the-evolving-any-type.txt:301,搜「not a function」);「最安全的是当 string 处理」见第 305 段(搜「safest thing」)。 ↩↩

  18. 出处:「The Evolving any Type」第 1139–1147 段(text/28-fm-the-evolving-any-type.txt:1147,搜「avoids the whole issue」)。 ↩

  19. 出处:「The Evolving any Type」第 1125–1137 段(text/28-fm-the-evolving-any-type.txt:1131,搜「Record<string, unknown>」)。 ↩↩

  20. 出处:「The Evolving any Type」第 1092–1111 段(text/28-fm-the-evolving-any-type.txt:1110,搜「key as keyof User」)。 ↩

  21. 出处:「The Evolving any Type」第 1113–1123 段(text/28-fm-the-evolving-any-type.txt:1119,搜「key as keyof typeof user」)。 ↩

  22. 出处:「The Evolving any Type」第 1149 段(text/28-fm-the-evolving-any-type.txt:1149,搜「two main choices」)。 ↩

  23. 出处:「The Evolving any Type」第 307–348 段(text/28-fm-the-evolving-any-type.txt:317,搜「isn't null or undefined」)。 ↩↩

  24. 出处:「The Evolving any Type」第 335–346 段(text/28-fm-the-evolving-any-type.txt:346,搜「don’t have properties」)。 ↩

  25. 出处:「The Evolving any Type」第 348 段(text/28-fm-the-evolving-any-type.txt:348,搜「elegant solution」)。 ↩↩

  26. 出处:「The Evolving any Type」第 845–884 段(text/28-fm-the-evolving-any-type.txt:881,搜「empty object annotation」)。 ↩

  27. 出处:「The Evolving any Type」第 350–367 段(text/28-fm-the-evolving-any-type.txt:362,搜「key as keyof typeof obj」)。 ↩

  28. 出处:「The Evolving any Type」第 369–388 段(text/28-fm-the-evolving-any-type.txt:377,搜「Cannot find name」)、第 385 段(搜「being used as a type」)。 ↩↩↩

  29. 出处:「The Evolving any Type」第 392–425 段(text/28-fm-the-evolving-any-type.txt:421,搜「typeof Song」)。 ↩↩

  30. 出处:「The Evolving any Type」第 427–461 段(text/28-fm-the-evolving-any-type.txt:461,搜「members of the enum」)。 ↩↩

  31. 出处:「The Evolving any Type」第 463–498 段(text/28-fm-the-evolving-any-type.txt:480,搜「as a value to access」)。 ↩

  32. 出处:「The Evolving any Type」第 487 段(text/28-fm-the-evolving-any-type.txt:487,搜「same thing」)。 ↩

  33. 出处:「The Evolving any Type」第 500–559 段(text/28-fm-the-evolving-any-type.txt:545,搜「alias」)。 ↩↩

  34. 出处:「The Evolving any Type」第 563–574 段(text/28-fm-the-evolving-any-type.txt:572,搜「export type Direction」)。 ↩↩↩

  35. 出处:「The Evolving any Type」第 576–645 段(text/28-fm-the-evolving-any-type.txt:622,搜「not a parameter」)。 ↩

  36. 出处:「The Evolving any Type」第 630–645 段(text/28-fm-the-evolving-any-type.txt:634,搜「'this' context」)。 ↩↩

  37. 出处:「The Evolving any Type」第 647–662 段(text/28-fm-the-evolving-any-type.txt:660,搜「cannot have a 'this' parameter」)。 ↩↩

  38. 出处:「The Evolving any Type」第 664–711 段(text/28-fm-the-evolving-any-type.txt:710,搜「implicitly has an 'any' type」)。 ↩

  39. 出处:「The Evolving any Type」第 712–731 段(text/28-fm-the-evolving-any-type.txt:730,搜「ignoring the arguments」)。 ↩↩

  40. 出处:「The Evolving any Type」第 732–743 段(text/28-fm-the-evolving-any-type.txt:741,搜「too few arguments」)。 ↩↩

  41. 出处:「The Evolving any Type」第 745–753 段(text/28-fm-the-evolving-any-type.txt:753,搜「doesn’t mean it has to use them all」)。 ↩↩

  42. 出处:「The Evolving any Type」第 1151–1214 段(text/28-fm-the-evolving-any-type.txt:1205,搜「CallbackType = (」)。 ↩

  43. 出处:「The Evolving any Type」第 755–843 段(text/28-fm-the-evolving-any-type.txt:806,搜「intersection of them」)。 ↩↩

  44. 出处:「The Evolving any Type」第 810–830 段(text/28-fm-the-evolving-any-type.txt:824,搜「{title: string; artist」)。 ↩↩

  45. 出处:「The Evolving any Type」第 1216–1282 段(text/28-fm-the-evolving-any-type.txt:1263,搜「{id: string} & {name: string}」)。 ↩

  46. 出处:「The Evolving any Type」第 1284–1324 段(text/28-fm-the-evolving-any-type.txt:1318,搜「not assignable to parameter of type 'never'」)。 ↩↩

  47. 出处:「The Evolving any Type」第 1326–1358 段(text/28-fm-the-evolving-any-type.txt:1336,搜「as never」)、第 1348–1356 段(搜「objOfFunctions.string(input)」)。 ↩

  48. 出处:「The Evolving any Type」第 1340–1344 段(text/28-fm-the-evolving-any-type.txt:1344,搜「any' is not assignable」)。 ↩↩

  49. 出处:「The Evolving any Type」第 1360–1372 段(text/28-fm-the-evolving-any-type.txt:1372,搜「inevitable decisions」)。 ↩

  50. 出处:「The Evolving any Type」第 1055 段(text/28-fm-the-evolving-any-type.txt:1055,搜「always declare the types」)。 ↩↩↩

  51. 出处:「The Evolving any Type」第 886–981 段(text/28-fm-the-evolving-any-type.txt:931,搜「const options: FetchOptions」);satisfies 姿势见第 949–959 段(搜「satisfies FetchOptions」);内联姿势见第 965–979 段(搜「myFetch({」)。 ↩