编程语言研究书库

并集、字面量与收窄 — TypeScript 的类型枢纽

并集、字面量与收窄 — TypeScript 的类型枢纽

这一章讲一件事: 一个值「是几种可能之一」时,TypeScript 怎么表达、怎么检查、怎么跟着你的代码变聪明。 工具是三个——并集、字面量、收窄;心智模型是一个——宽类型与窄类型。 原书把这一章的落点放得很高:它是全书从「记语法」转向「懂推断」的转折点,后面所有高级特性都站在这章的 vocabulary 上。

1. 顶层全景:一座梯子

  宽 ──────────────────────────────→ 窄
  unknown ──→ string ──→ "yes" | "no" ──→ "yes" ──→ never
  (什么都能放)  (任意字符串)  (两个具体值)   (恰好这个值)  (什么都不能放)

  并集( | )= 往宽处走;收窄(narrowing)= 往窄处走——用运行时检查走。
  never 在梯子最底:收窄「收过了头」,剩下的不可能状态就是它。

  图说:本章所有机制都是在这座梯子上上下下。记住两头——unknown 顶、never 底。

读法:越靠左的类型越「宽」(能装的值越多),越靠右越「窄」(更具体)。 下面每个机制都对应梯子上的一段移动。

梯子的三个用途:声明、检查、收窄

这座梯子不是装饰,它在实际代码里扮演三个角色, worth 先认清:

  ① 声明位置的选择:给一个值定类型,本质是在梯子上选起点。
     选得越宽(unknown、string),将来检查和收窄的工作量越大;
     选得越窄(字面量、并集),起点就越接近终点——但灵活性越差。
     「起点的选择」是全书被低估的设计决策:它决定了后面所有收窄的步数。

  ② 报错的翻译器:类型报错的深层含义几乎都是「这个值不在梯子的这一格」。
     'string' is not assignable to '"yes" | "no"' = 值在宽格,目标在窄格。
     会读梯子,报错就从天书变成坐标。

  ③ 收窄的地图:每个收窄机制(typeof、in、instanceof…)都是梯子上的一段
     「合法移动」。本章后面学的全部工具,都能标注在这张梯子上——
     学工具时问一句「它把我从哪格移到哪格」,记忆会牢一倍。

  图说:三用途合起来,梯子就是本章的「坐标系」——
  所有概念在其中定位,所有报错在其中翻译,所有工具在其中编号。

带着坐标系往下读,每一节的内容都能挂到梯子的某一格上:并集是横向的格拼接, 字面量是纵向的格细化,收窄是逆向的回溯,never 是坐标原点。 这套坐标系在第 17 章还会再见到一次——那里的类型谓词,就是「把梯子上的一格 封装成可复用的函数」的正式手法。

2. 并集类型:竖线的「或」

解决什么问题: JavaScript 里一个值今天是字符串、明天可能是 null,是常态。 比如这个值(为了演示挑了个玩具写法:时间戳是偶数就问候,否则给 null):

const message = Date.now() % 2 === 0 ? "Hello Tuesdays!" : null;
// 悬停 message → const message: string | null

悬停显示的类型 string | null 就是并集类型(union type)——「要么是字符串,要么是 null」, 不用你写,推断自己就造出来了1。

自己声明也简单:竖线分隔,每个类型叫并集的一个成员(member)2:

const logId = (id: string | number) => {
  console.log(id);
};

logId("abc"); // ✅
logId(123);   // ✅
logId(true);  // ❌ 红线:true 不在并集里

并集可以塞进类型别名(type Id = number | string),可以有很多成员、成员之间可以毫无关系, 太长时还有个排版技巧:竖线放到每行开头,一眼看清全体成员3。

并集的第一个反直觉:成员「毫不相关」也合法

初学并集时最容易卡住的观念是:并集的成员不需要有任何共同之处。 string | number、"yes" | "no"、Cat | Dog——可以,全部合法。这与很多 其他语言里的「或」直觉不同(那些语言往往要求可区分的共同超类型)。 TypeScript 的并集是「可能性的罗列」,不是「类型的树形分支」—— 这个自由是它表达力的来源,也是误用率的来源:罗列得越随意,收窄时工具越挑不对 (所以才有了「三个形状」的分类,见下文)。

第二个反直觉:并集没有运行时实体。string | number 不是一个「包装类型」, 运行时的值还是那个普通的字符串或数字,只是编译器脑子里多了「它可能是两种」的标注。 由此得到一个重要推论:并集的信息完全活在编译期——它免费,但也随擦除而消失; 想给运行时留下痕迹,得自己写判别子或校验(这就是判别联合和谓词的存在意义)。

并集的并集:自动合并

并集套并集,结果是一个大并集,不是嵌套结构:

type DigitalFormat = "MP3" | "FLAC";
type PhysicalFormat = "LP" | "CD" | "Cassette";
type AlbumFormat = DigitalFormat | PhysicalFormat;
// AlbumFormat = "MP3" | "FLAC" | "LP" | "CD" | "Cassette"

实际收益在函数边界上:收 PhysicalFormat 的函数只接三种实体格式,你传 "MP3" 当场报错—— 并集让每个函数只处理自己该处理的情况4。

把这个收益走完整。三个别名各管一摊:

type DigitalFormat = "MP3" | "FLAC";             // 数字格式:两种
type PhysicalFormat = "LP" | "CD" | "Cassette";  // 实体格式:三种
type AlbumFormat = DigitalFormat | PhysicalFormat; // 全部格式:五种(自动拍平)

然后是给三个不同函数各划边界:

const purchaseDigital = (format: DigitalFormat) => { /* 只管 MP3、FLAC */ };
const shipPhysical    = (format: PhysicalFormat) => { /* 只管 LP、CD、Cassette */ };
const listAlbum       = (format: AlbumFormat) => { /* 五种全收 */ };

purchaseDigital("MP3");   // ✅
shipPhysical("Cassette"); // ✅
shipPhysical("MP3");      // ❌ 红线:数字格式不在实体格式的并集里
listAlbum("FLAC");        // ✅ 宽并集兼容两个窄并集的全体成员

注意最后一行用到了第 3 节才正式登场的规则——窄进宽行:每个具体的格式都是 AlbumFormat 的子集, 所以两个窄并集的值都能进宽并集的槽位4。这套「分层的字面量并集」是业务建模的日常形态: 小并集表达领域分类,大并集表达全集,函数签名选择在哪一层设卡。

还有一个排版细节值得学:成员一多,把竖线提到每行行首——

type AllSortsOfStuff =
  | string
  | number
  | boolean
  | object
  | null
  | {name: string; age: number};

竖线对齐后,增减成员是单行 diff,评审时也一眼看清谁加谁减3。

并集的两个「形状」:成员相关与否,决定怎么收窄

并集成员之间的关系,决定你接下来用哪种收窄策略,值得在声明时就看清:

  形状一:成员互不相关(string | number | boolean)
    收窄工具:typeof(按值的种类分)
    特点:每个分支处理一类完全不同的值

  形状二:成员是同一父类型的窄类型("MP3" | "FLAC" | "LP")
    收窄工具:switch/=== 逐值对照(判别子是值本身)
    特点:值都是字符串,分的依据是「具体是哪个」

  形状三:成员是结构不同的对象(Circle | Square → 判别联合)
    收窄工具:判别子(in / ===)
    特点:分的依据是「对象的哪个标记」

  图说:三种形状对应三类收窄工具——typeof 分种类、=== 分值、判别子分标记。
  声明并集时先问自己「成员们是什么关系」,答案自动给出收窄工具。

这个「形状先行」的读法,把第 4 节的工具箱从「六个并列招式」整理成了「三选一」: 先看并集的形状,再选对应的收窄家族。第 7 章的判别联合正是形状三的成熟形态; 而第 17 章的类型谓词,则是给形状二和三造「可复用的收窄函数」的正式机制。

3. 字面量类型:把「或」收紧到具体值

解决什么问题: string 太宽了。「方向」不该是任意字符串,只该是 "up"/"down"/"left"/"right" 四个值。

字面量类型就是一个具体的值本身当类型:

type YesOrNo = "yes" | "no";
type StatusCode = 200 | 404 | 500;

YesOrNo 类型的值,只能是这两个字符串之一,连 "Yes"(大写)都不行5。

现在回看第 02 章那个悬案就有答案了:document.addEventListener 的第一个参数能补全出 DOMContentLoaded、mouseover…… 是因为它被声明成一大堆字符串字面量的并集6。 字面量并集 = 补全列表的来源,这是「类型服务工具」定位的又一次兑现。

宽类型与窄类型(本章的心智模型)

string 比 "small" 宽:string 装得下任何字符串,"small" 只装得下它自己。 反过来说 "small" 是 string 的窄类型。同理 404 比 number 窄、true 比 boolean 窄7。

这套关系的实用规则只有一条:

窄的可以放进宽的槽位;宽的放不进窄的槽位。

const logSize = (size: string) => { /* ... */ };
logSize("small"); // ✅ "small" 是 string 的子集

const logSize2 = (size: "small" | "large") => { /* ... */ };
logSize2("medium"); // ❌ 任意字符串放不进两个字面量的并集

学过集合论的话,这就是子类型与超类型的关系:"small" 是 string 的子类型8。 并集本身比它的每个成员都宽——所以 string | number 喂给只收 string 的函数会报错, 报错还会替你拆解:Type 'string' is not assignable to type 'number' 这行附注, 说的就是并集里那个放不进去的成员9。

「窄进宽行」的完整推论清单

「窄进宽行」一条规则,展开是四句话,各自独立有用:

  ① 变量可以向下收窄赋值:const s: string = "small" ✅(字面量进宽槽)
  ② 函数参数是宽的槽:传窄的值,永远安全
  ③ 返回值是窄的槽:返回宽的值,必须收窄或断言
  ④ 并集是「自动加宽的容器」:A | B 比 A 宽,所以 A 能进 A|B,反之必须判别

  图说:②和③的不对称,是函数签名天然存在的「进宽出窄」结构——
  参数求包容(来者不拒),返回值求精确(说到做到)。

第 ④ 条值得单独多看一眼:「A 能赋给 A | B」是让联合好用的基石—— 它让每个成员都可以单独参与联合,而不必先收窄成联合本身。 反过来,「A | B 不能直接当 A 用」正是第 4 节收窄存在的全部理由: 联合是宽的,用之前必须证明你拿到了哪一格。宽窄规则和收窄规则, 是一枚硬币的两面。

字面量并集的两个「日常工作」

字面量并集是全章使用频率最高的形态,它的两个日常工作值得点名:

  工作一:给「魔法值」安家。
    代码里的 "loading"/"small"/"CD" 这类魔法字符串,字面量并集是它们的家:
    type Direction = "up" | "down" | "left" | "right";
    从此方向值有了户口——拼错("up" 写成 "Up")编译期报错,
    补全能列出全部合法值,重构能安全重命名。第 02 章说过补全来自类型,
    这里就是源头。

  工作二:函数参数的「合法值白名单」。
    function move(direction: Direction) —— 参数的合法值集在签名里写死,
    调用方传 "diagonal" 当场报错。比运行时检查(「if 不是四个值就 throw」)
    早了一整个阶段:错误在写代码时被抓,不在跑代码时。

  图说:两个工作合起来,字面量并集 = 「魔法值的户口 + 参数的白名单」。
  它和 as const 对象(第 08 章)的区别:并集只管类型,不管值;
  要值也要类型时,用 as const 对象 + 推导(第 11 章)。

这套户口制度也是第 17 章类型谓词的用武之地: 当「验证一个值是不是合法成员」的逻辑要复用时,谓词把这套检查升格成函数—— 字面量并集负责「圈地」,谓词负责「验明正身」,两者配合才是完整的边界防御。

这套户口 + 白名单 + 验明正身的三段结构,也解释了为什么字面量并集是 「渐进类型」的最佳教具:它不要求你一下子设计完美的类型,只要求你给魔法值 起个集合名。从今天起,代码里每出现一个魔法字符串,就问「它的户口该落在哪个 字面量并集里」——这个习惯坚持一个月,代码库的字符串字面量会从「满天星」 变成「有户籍」,补全、检查、重构三个维度同时受益。这是全章投入产出比最高的 单一习惯,也是我们最想从本章带走的动作。这个动作还有一个隐藏的复利: 魔法值落了户之后,改名(Rename Symbol)就从「全局查找替换」升级成了 「编译器护航的重构」——第 02 章的导航三件套,在这里接上了它们的日常用例。 一环扣一环:类型户口 → 安全改名 → 大胆重构 → 更干净的类型—— 这就是本章习惯开启的正循环,也是全书「类型为工具」定位的最小完整演示。 演示到这里,本章的前四节(梯子、并集、字面量、宽窄)已经合成了完整的世界观; 接下来的第 4 节(收窄)到第 7 节(判别联合),是这个世界观的实战展开—— 从「认得」到「会用」,中间隔的就是这四节的全部内容。

4. 收窄:编译器跟着你的判断走(本章核心)

解决什么问题: 参数类型是 string | number 时,TypeScript 只敢给你两个成员的公共能力—— 字符串的 toUpperCase 和数字的 toFixed 都不能随便调。想按类型分别处理,怎么办?

收窄(narrowing):用运行时检查,把宽类型在特定代码范围内收成窄类型10。

主线例子,从头走一遍——一个处理唱片年份的函数:

const getAlbumYear = (year: string | number) => {
  if (typeof year === "string") {
    console.log(`The album was released in ${year.toUpperCase()}.`);
    //                                              ↑ 这里 year 是 string,toUpperCase 合法
  } else if (typeof year === "number") {
    console.log(`The album was released in ${year.toFixed(0)}.`);
    //                                              ↑ 这里 year 是 number,toFixed 合法
  }
};

逐格看编译器脑子里发生了什么:

  进入函数:      year = string | number(宽,只能调用公共能力)
  第 1 格(typeof === "string" 成立):year = string → toUpperCase 放行
  第 2 格(否则 typeof === "number"):year = number → toFixed 放行
  条件块之外:    year 回到 string | number——收窄只在代码块内有效

两条scope规则,原书点得极透:

  1. 收窄只在条件块的范围内有效。 出了 if,year 还是原来的并集—— 收窄是块级作用域的临时知识,不是永久改写11。
  2. else 分支收的是「剩下的成员」。 如果把并集扩成 string | number | boolean, 第一个 if 收走 string 之后,else 里剩的是 number | boolean—— 收窄会记账,减掉的不再回来12。

收窄不限于 typeof。原书列的完整工具箱13,以及各自的典型场景:

手段写法典型场景
typeoftypeof x === "string"区分原始类型
真值检查if (x) / && / ||排除 null/undefined/空值
throw / 提前返回if (!x) throw new Error(...)排掉空值后继续
in 运算符"data" in response区分「键不同的对象」
instanceoferror instanceof Error区分类的实例

下面把每一格走完。

真值检查的暗坑:空字符串也是 falsy

练习里的 validateUsername(string | null),第一种写法是 if (username) 真值检查—— 能通过,但有个坑:空字符串在 JavaScript 里是 falsy, "" 会走「不是字符串」那条路。对「用户名长度」这个场景恰好是想要的行为, 但记住这个暗坑,别把真值检查当类型检查用14。

「反着查」也认,存进变量也认

两个体现 TypeScript 「很懂」的细节:

// 反着查:typeof username !== "string" 成立 → username 必定是 null,直接 return
if (typeof username !== "string") {
  return false;
}
return username.length > 5; // 这里 username 收窄为 string

// 存变量:把检查结果存进 isUsernameOK,if 它等于 if 原始检查
const isUsernameOK = typeof username === "string";
if (isUsernameOK) {
  return username.length > 5; // 照样收窄
}

原书对这两条的评语:「TypeScript understands the reverse of a check. Very smart.」 「smart enough to understand that the value of the variable corresponds」15。

「存进变量也认」的边界:const 可以,let 不行

「存进布尔变量」这条有个必须知道的边界:只有 const 声明的变量能触发这个收窄传递:

const isString = typeof value === "string";
if (isString) { /* value 收窄为 string ✅ */ }

let isString2 = typeof value === "string";
if (isString2) { /* value 仍是原类型 ❌ */ }

原因还是那套「未来的可写性」逻辑(第 08 章拓宽的同款): let 变量在 if 执行前可能被重新赋值,编译器不敢假设它仍然等于当初检查的结果; const 不能重赋值,检查的结论在 const 的生命周期内永久有效,所以值的收窄可以 「挂靠」在 const 布尔上。这条边界和第 12 章练习 11-2 的「闭包偷走收窄」是 同一族规则:收窄只信任「不会被改写」的载体——const 变量、当前控制流, 都属此类;let 变量和跨闭包的引用,都不属。

JavaScript 的老毛病:typeof null 是 "object"

收窄时会撞上这个 JavaScript 历史遗留:typeof null === "object"。 练习里用它来反向排除——typeof username === "object" 成立就意味着是 null16。 后面 parseValue 大条件句里它还会再绊一次:typeof value === "object" 之后, value 仍然可能是 null,必须再补一道 value !== null17。记住这个组合,写运行时校验必踩。

throw 也是收窄:排掉就永久排除

const appElement = document.getElementById("app"); // HTMLElement | null
if (!appElement) {
  throw new Error("Could not find app element");
}
// 从这里往下,appElement 永远是 HTMLElement
console.log(appElement); // 悬停 → const appElement: HTMLElement

为什么 throw 能「穿透」块级作用域?因为抛出后后面的代码根本执行不到 null 的情况—— TypeScript 把「到不了」也纳入了收窄,提前返回(return)同理18。

throw/return 收窄的实战模式:守门条款

throw 和提前返回收窄组合起来,是生产代码里最常用的「守门」模式:

  模式:先排除所有「不可能继续」的情况,主逻辑在「干净」的假设下展开
    function process(elementId: string) {
      const el = document.getElementById(elementId);
      if (!el) throw new Error(`#${elementId} not found`);
      // 以下 30 行代码,el 都是 HTMLElement——不用写一次 if
      el.classList.add("active");
      el.textContent = "...";
      // ...
    }

  对比模式(不用守门):主逻辑里到处 if (el) {...} else {...},
    每多一层业务,嵌套深一层,可读性塌方。

  图说:守门条款用一次 throw 换掉 N 个 if——
  它的本质是「把异常情况的处理提到函数门口,让主路径保持直线」。

这个模式在 2026 年的社区里常以「early return / guard clause」的名字被推荐, TypeScript 的收窄机制是它的免费搭档:守门之后的代码,类型是精确的—— 不用守门模式,你也得写同样多的 if,只是类型在每个 if 里都是 X | null, 补全和检查全都打折扣。模式选择和类型收益在这里完美对齐,这是收窄机制 设计得最成功的一处。

in 运算符:键不同的对象,用键来分

主线例子换成网络响应。这是一个联合类型,两个成员的键不一样:

type APIResponse =
  | { data: { id: string } }
  | { error: string };

你想写 if (response.data) 直接查属性——报错:Property 'data' does not exist on type 'APIResponse'。 原因很直白:data 只存在于并集的一个成员上,直接访问等于逼编译器猜你是哪个成员19。

正解是 in:查「这个键在不在对象上」,在就是第一个成员,不在就是另一个:

const handleResponse = (response: APIResponse) => {
  if ("data" in response) {
    return response.data.id;   // 收窄 → 有 data 的那个成员
  } else {
    throw new Error(response.error); // 收窄 → 有 error 的那个成员
  }
};

悬停两个分支里的 response,类型各不相同——in 是专为「键不同的联合」设计的钥匙20。

(练习里还展示了一种绕法:给两个成员都补上 data?: undefined 之类的可选键:

type APIResponse =
  | { data: { id: string } }
  | { data?: undefined; error: string };  // 没有 data 时显式声明「data 是 undefined」

这样 if (response.data) 也能工作——两个成员都有 data 键了,真值检查自然合法。 能行,但原书明说有内置的 in,就别绕21:补键会把「这个成员根本没有 data」 改写成「这个成员有 data、值是 undefined」,类型变得更啰嗦,判别联合的清爽也打了折。)

instanceof:类的实例用类来分

try...catch 里的 error 是 unknown(原因见第 6 节)。要读它的 .message,先收窄:

if (error instanceof Error) {
  console.log(error.message); // error 收窄为 Error
}

instanceof 查的是原型链:TypeError 之类 Error 的子类也能通过。 但它拦不住有人 throw "一个不是错误的字符串"——原书给的防御是补一个 else 把原值抛回去22。

instanceof 的盲区:原型链查得到,跨线程查不到

instanceof 的机制是「查原型链」,这带来两个值得知道的边界:

  盲区一:跨 realm/跨线程的实例
    iframe、Web Worker 里构造的 Error,主线程里 instanceof Error 是 false——
    两个全局环境的 Error 不是同一个构造函数。
    跨线程代码收窄 error,该用结构化检查(「message in e && typeof e.message === "string"」)
    或第 17 章的谓词,而不是 instanceof。

  盲区二:它只对「类」有效,对「接口」无效
    interface 没有运行时实体,instanceof 永远查不到;
    接口联合的收窄只能靠判别子或键检查——这正是判别联合偏爱字面量判别子的原因之一。

  图说:instanceof 是「按血统认人」;血统在不同世界(全局环境)不通用,
  没有血统的东西(接口)根本无马可认。

第二个盲区尤其值得记住,因为它解释了 TypeScript 类型世界的一个不对称: 类有运行时身份(可以 instanceof),接口没有(只能靠判别子)。 设计联合类型时,如果成员需要跨模块/跨环境流动,判别子比 instanceof 更可靠—— 判别子是数据的一部分,跟着值走;instanceof 依赖构造函数的身份,留在构造它的那个世界。

收窄工具箱总表:六种工具的分工与暗坑

第 4 节的六个工具学完了,收成一张总表——日常收窄时对着选,选完留意对应暗坑:

工具分的依据适用并集形状暗坑
typeof值的种类(原始类型)形状一(不相关成员)null 算 "object",必须补 !== null
真值检查值是「空」还是「有」排 null/undefined/""/0""、0、NaN 都被误伤
throw / 提前返回排掉就永久排除所有形状无(最安全的收窄)
in键存在与否形状三(键不同的对象)键名拼错不报错(第 13 章教训)
instanceof原型链(构造函数身份)类的实例联合跨环境失效;对接口无效
判别子(===/switch)共同属性的字面量值判别联合(形状三成熟态)解构别拆散判别子和数据

选型口诀:先判断「分的依据是什么」(种类?空否?键?类?标记?), 再选对应的行——「依据」一词是收窄的全部本质:每种工具都是某种运行时证据的提取器, 选工具就是选证据类型。证据提取错了(in 查错键、instanceof 跨环境), 收窄就自信地把错路走完——这就是第 11 节边界「安全上限 = 检查正确上限」的展开。

5. unknown 与 any:「我不知道」对「我不在乎」

并集梯子有顶端:unknown 是 TypeScript 最宽的类型,代表「我不知道这是什么」。 什么都能赋给它:字符串、数字、函数、null……全部合法23。

它和 any 的关键差别,原书用一句话钉死:

unknown 是「I don't know what this is」,any 是「I don't care what this is」。24

「不知道」和「不在乎」在行为上完全不同:

const handleWebhookInput = (input: unknown) => {
  input.toUppercase(); // ❌ 红线:'input' is of type 'unknown'.
};

const handleWebhookInputWithAny = (input: any) => {
  input.toUppercase(); // any 不报错(拼错的 toUppercase 也放行!)
};

any 根本不进宽窄阶梯——它不是什么类型,是退出类型系统的开关;两头都占: 比所有类型宽、也比所有类型窄25。unknown 则守规矩: 放进任何东西都行,但想用它,先收窄。

这给了任何值一条标准的安全动线:进应用标 unknown,用之前逐步收窄。 外部输入(表单、webhook、JSON)尤其该走这条路。

用「巨型条件句」走一遍 unknown 的完整收窄链

原书练习里那道 parseValue 题,是 unknown 收窄的全程示范,值得在主线例子里走完最后一格:

const parseValue = (value: unknown): string => {
  if (
    typeof value === "object" &&      // 格 1:是对象吗?(注意:null 也算,见下)
    value !== null &&                 // 格 2:排除 null(typeof null === "object" 的坑)
    "data" in value &&                // 格 3:有 data 键吗?
    typeof value.data === "object" && // 格 4:data 是对象吗?
    value.data !== null &&            // 格 5:排除 null
    "id" in value.data &&             // 格 6:data 里有 id 吗?
    typeof value.data.id === "string" // 格 7:id 是字符串吗?
  ) {
    return value.data.id; // 七格全过:value 已被收窄到 {data: {id: string}}
  }
  throw new Error("Parsing error!");
};

七格,每格一种检查(typeof / 非 null / in),层层收紧,最后那个点号链才合法。 悬停整个函数:(value: unknown) => string——进口什么都吃,出口保证是字符串17。

原书对这坨代码的评价很诚实:这么写是个烂摊子,真实项目该用 Zod 这类库把运行时校验和类型推导一次性做好; 但亲手写一遍这个条件句,是理解 unknown 和收窄的最好办法26。

判断(我们的,不是书里的): 这一节把 unknown 的定位讲得非常准,而且比多数教材早: 在 2026 年的实践中,「外部数据先进 unknown,schema 校验后变具体类型」已是主流项目的默认动线 (Zod、Valibot、ArkType 都是这个范式的实现)。原书把 Zod 点名为「更体面的做法」, 等于承认类型系统管不到运行时真假——这是第 05 章「type faith」警示的正面对案。 如果错,会错在: 如果类型推导能从「带验证的解析」进一步自动化(比如编译器联合同步 schema), 手写收窄链的最后一点存在感也会消失——但「运行时验证不可省」这个结论本身不会动摇。

unknown 的两个日常来源

到这里你已经不知不觉见过 unknown 三次了,把它们摆在一起,模式就清楚了:

来源场景为什么是它
空容器new Set() 悬停是 Set<unknown>(第 05 章)没有值可供推断,元素类型只能是最宽的
catch 子句catch (error) 里 error 是 unknown抛出来的可以是任何值,TypeScript 拒绝猜测
外部输入webhook、表单、JSON.parse 的返回值数据在运行时边界之外,编译时不可知

三个来源的共同结构是:「编译器没有信息」的那一刻,安全的默认值就是 unknown。 它不是惩罚,是类型系统在信息真空里的诚实姿态——宽到什么都装得下,但不给你任何使用权限, 逼你在使用前交出证据(收窄)。any 则是同一个真空里的不诚实姿态:假装有信息,什么都放行。

这也解释了为什么 new Set(["CD", "DVD"]) 能推断出 Set<string>: 信息一出现(两个字符串字面量),真空立刻被填上,unknown 退位10。

unknown 的收窄纪律:先 typeof,再细化,别把两跳合并

实战中收窄 unknown 有一个常见的「合并跳步」失误——把两步检查挤进一句, 结果类型系统跟不上:

// ❌ 合并跳步:if 里 typeof 检查了,但在 && 的同一句里访问属性
if (typeof value === "object" && value.data !== undefined) { /* ... */ }
//  value.data 报错:object 类型上没有 data 属性

问题在 && 的左操作数只把 value 收窄到 object,右操作数仍在这个未进一步收窄的 object 上求值——object 上访问任意属性都不合法(第 13 章「Object.keys 的固执」 是同一个规则的变体)。正解是拆开,让收窄在每一行生效后再走下一步: 先 typeof value === "object",再 value !== null,再 "data" in value—— 巨型条件句那道例题的每一格,都是在防这个跳步失误9。

收窄 unknown 的总纪律:一行只完成一格移动,每格移动后立即悬停确认。 unknown 是梯子顶,从顶走到任意一格都可能要好几步;想一步跨越多格的类型断言 (如 value as MyType),本质上是把证据链换成了一句承诺——和第 12 章的 type faith 一脉相承。

这条纪律在 2026 年的项目里有一个「自动驾驶」版本:schema 校验库(Zod 等)把 「逐格收窄」换成了「一次声明式校验」——校验通过那一刻,值的类型从 unknown 直接 跳到你声明的形状,中间的格子由库代走。但纪律本身没有过时:写 schema 声明时, 你仍在逐格思考「这个字段是什么、可选吗、什么形状」——只是思考的载体从条件句 变成了模式声明。从手写收窄链到 schema 校验,变的是书写形式,不变的是 「一格一格想清楚」的思维动作。 原书把 Zod 点名为「更体面的做法」, 但没有说它可以替代思考——这条补充是我们加的,也是本章与第 17 章谓词之间的 分工边界:谓词封装「一格」,schema 封装「一整段」,手写的巨型条件句则是 理解两者的最佳教具。

6. never:梯子最底,「不可能」本身

unknown 在最顶,never 在最底:它代表「永远不会发生」。你很少主动标注它, 它常从报错和悬停里冒出来27。

它长什么样?看一个永远抛错的函数:

const getNever = () => {
  throw new Error("This function never returns");
};
// 悬停 → const getNever: () => never

普通空函数悬停是 () => void(「返回可以忽略的东西」); throw 的函数是 () => never——「这个函数不会有返回这回事」28。

never 的行为初看很怪,两条规则:

  1. 什么都不能赋给 never(除了 never 自己):fn("hello")、fn(42) 全报错29。
  2. never 能赋给任何东西:const str: string = getNever() 反而合法29。

第二条为什么合理?想想 never 的含义:「这个值不存在」。 一个不存在的值,放进任何槽位都造不出矛盾——空集是任何集合的子集。 (数学背景:补充(不在书里,来自通用知识): 这就是「底类型」(bottom type)的标准行为, Haskell 的 Void、Scala 的 Nothing 是同类设计。)

把这两条规则跑一遍原书的实验,印象会深得多。先声明一个只收 never 的函数:

const fn = (input: never) => {};

fn("hello"); // ❌ 全报错:字符串不是 never
fn(42);      // ❌
fn({});      // ❌
fn(fn2());   // 只有这一行不报错——因为 fn2 返回 never,赋给 never 是 never 对 never

再把它放到接收端:

const str: string = getNever();  // ✅ never 是任何类型的子类型
const num: number = getNever();  // ✅ 同理

原书自己说这套行为「第一眼很怪,以后会看到它为什么有用」29—— 那个「以后」就在第 16 章:当你对一个判别联合写 switch, 所有 case 都处理完后,default 分支里那个值的类型就是 never; 如果有人往联合里加了新成员而你的 switch 没跟进, 那个新成员的类型就漏进了 default——把它标成 never 的参数一接, 编译器当场报警。never 是类型系统给「不可能」留的哨兵岗。

never 的日常用处在收窄里:把并集的每个成员都收完之后,「剩下的」就是 never—— 第 16 章的穷尽性检查会用它做「漏了 case 就报错」的机关。

never 的两个「体感实验」

never 的抽象定义(「不可能」)配上两个实验,手感立刻具体:

  实验一:收窄的「终点显示」
    function f(x: string | number) {
      if (typeof x === "string") { /* x: string */ return; }
      if (typeof x === "number") { /* x: number */ return; }
      /* x 此刻的类型?悬停 → never */
    }
    两个成员都收完了,第三格不可能存在——x 被收窄到了 never。
    悬停看到 never,就是「这个变量的可能性被我榨干了」的信号。

  实验二:never 的「什么槽位都能放」
    function assertNever(x: never) { /* 永远不该被调用 */ }
    这个函数的参数是 never——意味着它只接受「不可能存在」的值。
    在正常代码里永远调不了它;但 exhaustiveCheck(x) 里如果 x 不是 never,
    编译器立刻报警:有人往联合里加了新成员,而你的 switch 没跟上。

  图说:实验一展示 never 的「来源」(榨干可能性),实验二展示它的「用途」
  (穷尽性检查的哨兵)。同一个类型,一头一尾,都是「不可能」这个语义在值班。

这两个实验也预告了第 16 章的核心技巧:把「不该发生的事」变成编译错误, never 是这类机关里唯一的原料。学不会 never,第 16 章的条件类型和穷尽检查 就只是咒语;学会了,它们只是「把实验二写成类型工具」的机械步骤。

补一个 never 的「冷知识级」事实,帮它扎根:never 是任何类型的子类型, 但没有任何类型是它的子类型(any 除外——any 两头占,第 5 节讲过)。 这意味着 never 值可以放进任何槽位(const x: string = neverValue 合法), 但没有任何值能放进 never 槽位——「空集是任何集合的子集」的集合论事实, 在类型系统里的逐字翻译。这个不对称解释了为什么 assertNever 式的哨兵有效: 参数标 never = 「只接受不可能」,而任何真实值都是「可能」,一传就报错。

7. 判别联合:给「可选大杂烩」动手术

先看病:bag of optionals(可选属性大杂烩)

建模一次数据请求的状态,直觉写法是这样:

type State = {
  status: "loading" | "success" | "error";
  error?: string;
  data?: string;
};

然后渲染函数里出事了:

if (state.status === "error") {
  return `Error: ${state.error.toUpperCase()}`;
  //                            ↑ 红线:state.error 可能是 undefined
}

status 明明是 "error",error 却还可能不存在——因为类型声明里 error、data 和 status 之间没有任何关系。更糟的是,这个类型还允许「不可能的状态」:

const state: State = {
  status: "loading",
  error: "这是个错误",  // loading 状态不该有错误!
  data: "这是数据",     // loading 状态不该有数据!
};

原书给这种类型起了名:bag of optionals(可选属性大杂烩)—— 一个宽松的袋子里,所有属性都可能出现,组合出运行时永不发生的状态30。

药方:判别联合(discriminated union)

解决什么问题: 让「error 只在 error 状态存在、data 只在 success 状态存在」成为类型层面的事实。

判别联合 = 每个成员都有一个共同属性,这个属性的值是各不相同的字面量(判别子,discriminant)。 把 State 拆开:

type State =
  | { status: "loading" }
  | { status: "error"; error: string }
  | { status: "success"; data: string };

status 就是判别子:三个成员各有一个字面量,互不混淆。 渲染函数一字不改,红线消失——state.status === "error" 成立的那一刻, TypeScript 就锁定了那个成员,state.error 必定是 string31。

这个模式值得花一分钟理解它为什么有效,因为整个第 07、11、16 章都建筑在它上面:

  bag of optionals:           判别联合:
  status + error? + data?     {status:"loading"}
  (三者自由组合,            | {status:"error", error}
   状态空间 3×2×2=12 种,     | {status:"success", data}
   多数不可能)               (每个状态一个形状,只允许 3 种)

  图说:判别联合不是「加类型」,是「删不可能」——把状态空间砍到和运行时一致。

工程上还可以每个成员起一个别名(LoadingState、ErrorState、SuccessState), 再并起来32。原书给的使用信号很干脆: 「如果你的类型长得像 bag of optionals,考虑重做成判别联合」33。

判别子的选择:三个合法形态和一个反模式

判别子不一定叫 status/kind,合法形态有三种,选择标准各一条:

  ① 字面量字符串("loading" | "error"):最常见。
     选它的标准:值会被序列化(进 JSON、URL、日志)——字符串最可读。
  ② 数字枚举式(0 | 1 | 2):压缩场景(协议带宽敏感)。
     代价:可读性差,日志里一堆数字要靠解码表。
  ③ true/false 判别({ success: true, ... }):两态场景(Result 型)。
     选它的标准:布尔语义本身就是业务语言(成功/失败)。

  反模式:判别子用「可能重叠的值」。
    判别子的全部威力来自「各成员的值互不混淆」;
    一旦判别子的值有重叠(如两个成员都用同一个字符串),
    收窄时编译器无法区分成员,判别联合退化成普通联合。

  图说:判别子是整个模式的支点——支点必须精确互斥,杠杆才撬得动收窄。

还有一个容易忽略的设计决定:判别子的值就是日志文本。判别子的值会写进 日志、DevTools、网络面板——status: "loading" 的调试价值十倍于 status: "LDR", 尽管两者类型能力相同。给判别子起值时,把它当「会出现在报错截图里的用户可见文本」 来对待,而不是编译器的内部记号——这是判别联合设计里唯一「类型能力相同、 工程质量不同」的自由度,值得用足。

还有一个容易被忽略的设计决定:判别子放哪个属性名。成员之间的判别子名必须一致 (都叫 status,不能一个叫 status 一个叫 state)——名字一致,typeof/in/switch 才能统一地施于整个联合。团队里给判别子起名时,一致性比表达力更重要: 一个联合里叫 status 的判别子,全项目的联合都该叫 status 或 kind,不要百花齐放。

switch 是判别联合的最佳搭档

function calculateArea(shape: Shape) {
  switch (shape.kind) {
    case "circle": {
      return Math.PI * shape.radius * shape.radius;
    }
    case "square": {
      return shape.sideLength * shape.sideLength;
    }
  }
}

两个红利:

  • case 有补全——敲完 case 自动列出还没处理的判别子,天然防漏34;
  • 漏了就露馅——把 "square" 分支删掉,悬停函数名,返回类型变成 number | undefined: TypeScript 知道 square 进来时函数什么也没返回35。 (number | undefined 这个「漏 case 的信号」,第 15 章配置项会让它变得更显眼。)

解构的边界:别把判别子和数据拆散

练习里有个很能暴露「收窄靠什么」的翻车现场:

// ❌ 在参数列表里直接解构
function calculateArea({kind, ...shape}: Shape) { /* ... */ }
if (kind === "circle") {
  const {radius} = shape; // 仍然报错!
}

拆开的一瞬间,kind 和 radius 的关联被切断了——shape 失去判别子, 编译器不再知道它是什么36。正确姿势:参数保持完整,解构挪进分支里做:

function calculateArea(shape: Shape) {
  if (shape.kind === "circle") {
    const {radius} = shape; // ✅ 先收窄,再解构
    return Math.PI * radius * radius;
  } /* ... */
}

结论:跟判别联合打交道时尽量别在参数处解构;要解构,就在分支里解构37。

判别元组:元组也能当联合成员

响应值不想用对象?元组一样能判别:

type APIResponse = ["error", string] | ["success", User[]];

第一个元素就是判别子。配合解构 const [status, value] = await fetchData(), 先查 status === "success",value 自动收窄成 User[]—— 编译器连「分别解构出来的两个变量之间的关联」都替你盯着38。 这个「关联追踪」是判别元组特有的;判别对象拆开后没有这么聪明39。

当判别子可以省略:改运行时,别改类型

新需求:不传 kind 时默认当圆形。两种失败尝试都有教训:

  1. 加一个没有判别子的成员(OptionalCircle)→ 判别联合直接瓦解, shape.kind 访问报错——判别联合要求每个成员都有判别子40;
  2. 把判别子改可选(kind?: "circle")→ 判别保住了,但 else 分支收窄不动, 因为「undefined 是什么形状」没有了答案41。

正解出人意料地简单:把条件倒过来,先查 square,else 归圆形:

if (shape.kind === "square") {
  return shape.sideLength * shape.sideLength;
} else {
  // 非 square 即是圆形(含没传 kind 的)
  return Math.PI * shape.radius * shape.radius;
}

判别子可选的 kind?: "circle" 保留(它让「不传 kind」合法), 而运行时逻辑翻个面,让 TypeScript 的收窄重新生效。 原书给这招的评语很妙:「Sometimes, flipping the runtime logic makes TypeScript happy!」42

「改运行时别改类型」的分寸

这一节的原则值得再精确半句,因为「改运行时」不是万能药:

  该改运行时的信号:运行时逻辑本来就有两种等价写法,
    只是其中一种碰巧和收窄的方向一致——比如「先查正例」vs「先查反例」。
    此时选和类型一致的那版,零成本。
  不该改运行时的信号:运行时逻辑有真实依据(协议顺序、性能考虑、可读性),
    反转会让代码变差——此时该动的是类型(给缺失的判别子补默认值,
    或干脆把「可选判别」改成显式的三成员联合)。
  判断依据一句话:让代码服务类型,还是让类型服务代码?
    答案永远是后者——类型是投影,代码是本体。

  图说:收窄机制是「代码形状」和「类型形状」的对齐检查;
  对齐失败时,优先调整成本低的那一边,但底线是不损伤代码的本义。

这条分寸也是和第 12 章「顺流游泳」的对照:那里说「改模式,别压制报错」, 这里说「改运行时,别强改类型」——两处共同的立场是:类型系统应该向代码的真实 形状妥协,而不是让代码向类型系统的便利妥协。妥协的方向一旦反了 (为了迁就类型而写假逻辑),类型就从「诚实的投影」堕落成「牵强的谎言」, 第 12 章 type faith 的账单就会连本带息地回来。

把判别联合当状态机用:renderUI 的完整走查

判别联合最常见的用途是给状态建模。把第 7 节的数据请求例子走完整——先是类型的最终形态:

type LoadingState = { status: "loading" };
type ErrorState   = { status: "error";   error: string };
type SuccessState = { status: "success"; data: string };
type State = LoadingState | ErrorState | SuccessState;

然后是消费它的函数,逐分支看编译器手里的类型:

const renderUI = (state: State) => {
  if (state.status === "loading") {
    // state 收窄为 LoadingState
    return "Loading...";
  }
  if (state.status === "error") {
    // state 收窄为 ErrorState —— state.error 必定是 string,toUpperCase 放行
    return `Error: ${state.error.toUpperCase()}`;
  }
  if (state.status === "success") {
    // state 收窄为 SuccessState —— state.data 必定是 string
    return `Data: ${state.data}`;
  }
};

bag of optionals 版本里那道 state.error 可能是 undefined 的红线,在这里从根上消失: 不是收窄变聪明了,是类型声明里根本不再存在「error 状态没有 error 字段」这种值31。 注意这个「从根上消失」和「靠收窄压下去」的差别——判别联合把错误消灭在定义处, 而不是检查处。这是它和可选属性大杂烩的本质分野,也是它值得单独命名的原因。

「定义处消灭」的工程回报:三份自动红利

把错误消灭在定义处,回报不是一处报错,而是三份自动产生的红利:

  红利一:补全自动化。renderUI 里输入 state. 的瞬间,
    补全列表只列当前状态有的字段——不存在的东西根本不进列表,
    「拼错字段」这种 bug 被补全直接预防。
  红利二:漏写信号。switch 少写一个 case,返回类型变成 number | undefined,
    调用方一用就报警——不是立即爆炸,而是类型层面的「此处 unfinished」标记。
  红利三:重构安全。给联合加一个新状态(如 "refreshing"),
    所有 switch 的 default/返回类型立刻报警,列出所有没跟进的消费点——
    这是手工维护 switch 永远做不到的全局一致性检查。

  图说:三份红利都来自同一个源头——「类型即状态空间」的建模让编译器
  参与了状态机的维护。判别联合不是写起来更麻烦的类型,是雇了一个
  不知疲倦的状态机审计员。

审计员的工钱为零(运行时类型擦除,判别联合不产生任何运行代码),这让它的 投入产出比在整个类型系统里数一数二。一个团队从 bag of optionals 迁到判别联合, 平均能在 code review 里省掉一大半「这个状态下这个字段到底有没有」的讨论—— 讨论被类型声明回答了,评审就可以专注于业务逻辑。这是原书把判别联合放在 全章压轴的真正原因:它是本章全部机制(并集、收窄、字面量、never)的合奏, 也是「类型驱动设计」这个全书主题的第一次完整显形。

判别元组的完整样本:fetchData 的返回值

对象版判别联合的另一种形态是元组版,原书用一个异步请求函数走了一遍。 需求:请求成功返回用户数组,失败返回错误信息,而且「成功配错误信息」「失败配用户数组」 这两种张冠李戴不许出现。

type APIResponse = ["error", string] | ["success", User[]];

async function fetchData(): Promise<APIResponse> {
  try {
    const response = await fetch("<示例接口地址>");
    if (!response.ok) {
      return ["error", "An error occurred"];
    }
    const data = await response.json();
    return ["success", data];
  } catch (error) {
    return ["error", "An error occurred"];
  }
}

消费端把「关联追踪」演给你看:

async function exampleFunc() {
  const [status, value] = await fetchData();
  // 悬停 status → "error" | "success"

  if (status === "success") {
    // value 自动收窄为 User[] —— 不需要碰 status 以外的任何东西
    console.log(value);
  } else {
    // value 自动收窄为 string
    console.error(value);
  }
}

status 和 value 是两个各自独立的变量,但 TypeScript 记得它们来自同一个元组的同一趟解构, 检查一个、另一个跟着窄38。原书特意注明:这种「拆开了还认得」的智能是判别元组特有的, 判别对象一旦先解构再判断就没有这个待遇(所以上一节才说「先收窄,再解构」)39。

至于为什么元组版有时比对象版顺手:返回值不用起键名,顺序本身就是约定 (["error", string] 的第一个槽位永远先说状态)。代价是可读性——三个槽位以上的元组 就该回头用对象了(这是我们的经验补充,原书未评)。

判别元组 vs 判别对象:什么时候选哪个

把两种判别形态并排,选择的依据就清晰了:

维度判别对象({status, error?})判别元组(["error", string])
数据有没有键名有,自文档化无,靠位置约定
序列化友好度高(键名进 JSON)低(数组进 JSON,语义丢失)
解构后的收窄不行(关联被切断)行(元组特有的智能)
扩展新字段加键即可位置乱了全崩
适用规模中大型状态两态的小返回值

日常默认选判别对象(键名就是文档);判别元组留给「两态、短生命周期、 不序列化」的小场景——典型的如返回 [boolean, T | Error] 的工具函数。 选元组时顺手定一条团队约定:位置 0 永远是判别子,永远不写超过三槽。

这套对照表也给本章的「判别联合」主题收了尾:判别联合的全部变体(对象版、元组版、 带别名的成员版、判别子可选的折中版)共享同一个内核——用一个公共的、互斥的标记 换取类型系统的全程跟随。变体之间的选择是工程权衡(键名 vs 位置、序列化 vs 解构), 内核的原理不变。记住这个内核,将来在任何语言、任何类型系统里见到 「tagged union / sum type / variant」时,你认出的都是同一个东西: TypeScript 叫它 discriminated union,它是代数数据类型思想在工业语言里的标配形态 (这一层联系原书未展开,补充,来自通用知识)。

8. 三个完整练习样本(把机制用到底)

前面是按机制讲的;原书的练习里还有三个「完整走一遍」的样本,它们各自把几种收窄手段串成防线, 值得收进主线。三个样本的难度是递增的:一个函数、五种写法、七格条件。

样本一:为什么非要并集不可 —— getUsername

需求:用户名可能来自数据库记录,用户注册时不一定填了名字——所以参数得同时接受字符串和 null。

// ❌ 只收 string:null 传进来直接红线
function getUsername(username: string) {
  if (username !== null) {
    return `User: ${username}`;
  } else {
    return "Guest";
  }
}
getUsername(null); // Argument of type 'null' is not assignable to parameter of type 'string'.

注意这个 if 写得没问题,错在类型太窄——!== null 的检查对一个「根本不可能收到 null」的参数是死代码。 改成并集:

function getUsername(username: string | null) {
  if (username !== null) {
    return `User: ${username}`;
  } else {
    return "Guest";
  }
}

红线消失,而且 if 里面的检查从此有了意义:并集让 null 成为合法输入, 收窄负责把 null 挡在 ${username} 插值外面43。

同一章另一头还有一个「反着用」的练习:函数收 direction: string 时, move("up-right", 10) 这类胡闹调用全部合法,@ts-expect-error 反而因为「没报错」而亮红线—— 类型太宽,检查失灵。收紧成字面量并集后,非法方向当场被拦,还白得一份方向名补全44:

function move(direction: "up" | "down" | "left" | "right", distance: number) { /* ... */ }

两个练习是一对镜像:并集太窄,合法输入进不来;基本类型太宽,非法输入拦不住。 类型设计就是在宽窄之间找那个恰好。

样本二:同一道题的五种收窄 —— validateUsername

题目:validateUsername(username: string | null) 里直接写 username.length 报错 (可能是 null)。五种通过练习测试的写法,按「教了什么」排序:

  写法 1  if (username)                     真值检查;坑:空字符串被当 null 处理
  写法 2  if (typeof username === "string") 最直白;空字符串走 else,行为正确
  写法 3  if (typeof username !== "string")  反查:不是字符串就必是 null,先返回
          return false;                     ——「反着查也认」的实证
  写法 4  if (typeof username === "object")  利用 typeof null === "object" 的怪癖反杀
  写法 5  const ok = typeof username === "string";
          if (ok)                            检查存进变量,收窄照常生效

45

原书在写法 5 之后给的总结是这节的题眼: 「无论选哪种,记住:用 if 收窄类型,然后在条件通过的分支里写代码。」46 写法 2、3、5 是日常该用的;写法 1 的 falsy 坑和写法 4 的怪癖,知道即可,别当武器。

五种收窄的选择顺序:一个可执行的排序

把这五种按「安全性 × 简洁性」排序,得到一个可以直接执行的日常顺序:

  第一选择:typeof 检查(最明确,零歧义)
  第二选择:存进布尔变量的判别(可读性最高,复杂条件的最佳载体)
  第三选择:显式 !== undefined(null 联合)或 !== null(可选链的搭档)
  谨慎使用:真值检查(只在「空值即无效」的语义下用,先确认 "" 和 0 不在合法值集里)
  避免:检查存进变量后再 if(收窄失效的怪癖形态,见写法 4)

  图说:排序的依据是「语义明确度」——类型检查越直白,读者越少猜;
  模糊度越高(真值、双重否定),越需要注释或重构来补语义。

这个排序也值得和团队的 lint 规则对齐:很多项目配置了 strict-boolean-expressions 一类的规则,把真值检查限制在「明确是 boolean 的表达式」上——lint 规则和 这个排序是同一个判断的自动化版本。写代码时按排序选,review 时按排序审, lint 把前两档固化——三管齐下,收窄写法的一致性就有了制度保障, 而不是靠个人记忆。

样本三:try/catch 的完整防线 —— somethingDangerous

题目:一个一半概率抛错的函数,你要在 catch 里打印 error.message——但 error 是 unknown, 点号一碰就红线47。

const somethingDangerous = () => {
  if (Math.random() > 0.5) {
    throw new Error("Something went wrong");
  }
  return "all good";
};

try {
  somethingDangerous();
} catch (error) {
  // error:unknown ——什么都做不了
}

第一格(必须):instanceof。

} catch (error) {
  if (error instanceof Error) {
    console.error(error.message); // error 收窄为 Error,message 存在
  }
}

到这一步,TypeError 等 Error 子类也覆盖48。但 JavaScript 允许 throw "任何值", 真有人扔字符串,这个 if 就静静跳过——第二格(防御):else 把原值抛回去。

} catch (error) {
  if (error instanceof Error) {
    console.error(error.message);
  } else {
    throw error; // 不认识的,别吞掉
  }
}

原书给的定性:这才是「安全地处理错误、避免潜在运行时错误」的完整形状49。 把这三格和 parseValue 的七格放在一起看,模式是同一个:

  unknown 进门 → 一道一道检查(类型?类?键?null?)→ 每过一道,类型窄一分
  → 全过:拿到具体类型,放手用;任何一道失败:进错误分支,别硬来。

  图说:这就是「unknown 安全动线」的最小完整形态。真实项目把中间的检查换成 schema 库。

样本三的隐藏课程:错误处理也是类型设计

样本三(try/catch 防线)除了演示收窄,还藏了一门类型设计课:

  catch 的 error 是 unknown —— 这不是 TypeScript 的刁难,是一个设计宣言:
  「抛出的值没有契约」。任何代码都可能 throw 任何东西(字符串、数字、自定义对象),
  把 error 声明成 Error 之外的「已知类型」,本身就是一种 type faith。

  于是完整的错误处理防线变成三层:
    第一层:instanceof Error —— 接住「讲规矩的错误」
    第二层:结构化检查(键/类型) —— 接住「不规则但有信息的错误」
    第三层:else 重新抛出 —— 对「完全不认识的东西」不做任何假设,原样上交

  图说:三层防线对应三种「错误的规矩程度」;
  第三层是大多数人漏掉的——它的缺失不会让代码报错,只会让未知错误静默消失,
  排查时才知道代价。

这个三层结构也是第 17 章断言函数的自然舞台:assertIsError(e) 把第一层封装成 可复用的把关函数,handleRequest 式的调用点代码缩到两行。样本三教的是「为什么 要三层」,第 17 章教的是「怎么把每层变成函数」——两章隔着十章相望,教材的 伏笔埋得又长又稳。

三个样本合成一条「并集决策线」

三个样本各示范了并集的一种用法,串成线就是完整的选型路径:

  值有两种(或更多)可能 → 先问「可能的来源是什么」:
    业务枚举(方向、格式、状态码)      → 字面量并集(形状二)
    运行时不同种类(字符串或数字)      → 宽类型并集 + typeof 收窄
    结构化状态(error/data 互斥)       → 判别联合(形状三)
    完全未知(外部输入)                → unknown,收窄链走完

  图说:四个出口对应四种并集形态;第一问是「来源」,第二问才是「工具」。
  选错形态的特征很明显:字面量并集里塞了结构、判别联合里塞了未知输入——
  见到这类「并集不像并集」的代码,多半是第一问问错了。

这条决策线也是本章对全书的「接口规格」:第 07 章的对象类型、第 11 章的推导、 第 16 章的泛型,全部假设你已经能按这条线把「值的可能性」分类归位。 它值得和「梯子的三个用途」并列,作为本章的两张总图之一。

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

到这里,类型系统的「表达—检查—推断」三环齐了:并集与字面量负责表达「几种可能」, 宽窄类型刻画检查的方向(谁进谁的槽位),收窄与判别联合推断出运行时分支里的具体形状。 原书小结把这章收在判别联合上——「消灭不可能状态」这个思想,会一路延伸到第 07 章的对象组合、 第 11 章的类型推导、第 16 章的条件类型(它在类型层面做 if-else)50。

把本章在全书的坐标再压一张图:它是「分水岭左侧」的最后一站——

  分水岭左侧(值的形态):第 04 章形状 → 第 05 章复合 → 本章「可能性」
  分水岭右侧(值的锁与边界):第 08 章可变性 → 第 09 章类 → 第 10 章 relics
  分水岭之后(类型的机器):第 11 章推导 → 第 12 章沟通 → …… → 第 16/17 章设计

  图说:左侧教「值可以是哪些样子」,右侧教「值能被谁碰」,之后教「类型怎么造机器」。
  本章是左侧的收官——把「值的可能性」这个维度补齐后,值的全部基本形态宣告讲完。

这也是本章的阅读顺序建议:如果读者是从第 04 章一路顺序读来的, 本章是「值的形态」三部曲的终章,值得慢读——判别联合是三部曲的压轴概念, 理解了它,前面所有章节的类型设计决策(为什么第 02 章的补全能列出事件名、 为什么第 05 章的 Set 需要类型参数)都会在本章找到最终的解释。 如果读者是跳读的,本章只读第 1 节(梯子)、第 4 节(收窄)、第 7 节(判别联合) 三节,也能拿到全章八成的收益——三节正是「可能性」这个主题的「表达、验证、建模」 三个阶段的代表。

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

书里有据的:

  • 宽窄类型、子类型关系、并集比成员宽——语言语义事实,配了可运行的正反例789。
  • 收窄的块级作用域与「else 收剩余成员」——机制描述,用三成员并集演示了记账行为1112。
  • unknown vs any 的「不知道/不在乎」——概念辨析,配了属性访问的正反实验2425。
  • 「判别联合消灭 bag of optionals」——设计模式主张,论证链是「12 种状态里多数不可能 → 类型应只许 3 种」3031。
  • 「判别对象别在参数处解构」——由失败尝试反推出的边界,解释是「判别子与数据的关联被切断」3637。
  • 判别子必须人人有份(否则收窄不认)、in 比补键更优——语言规则,配了失败的替代方案21。
  • instanceof 查原型链(Error 子类通过)、防非 Error 抛出要补 else——机制事实配防御代码22。
  • 真值检查的 falsy 坑("" / 0 被误伤)——JavaScript 语义在类型系统里的显形15。

原书没说的(我们补):

  • never 对应学术上的「底类型」(bottom type)——补充(不在书里,来自通用知识), Haskell Void、Scala Nothing 同源;never 在穷尽性检查中的用法(第 16 章展开)也是学界的标准技巧。
  • Zod 等 schema 校验生态的现状(2026 年)——补充:原书只点了名,具体选型与版本以当下文档为准。
  • typeof null === "object" 的历史成因——补充(来自通用知识): 这是 1995 年 JavaScript 首个实现留下的兼容性问题,ECMAScript 规范至今无法修正。

判断(我们的,不是书里的): 如果把全书十六个原书章按「工程回报」排序, 我们会让这一章排第一,而且理由集中在一个模式上:判别联合 + switch。 它是「类型即状态机」这个思想在工业语言里最成熟的形态: 一次定义,编译器同时给你补全、检查、漏 case 信号三种回报,运行时零成本。 学 TypeScript 学到能顺手把 bag of optionals 重构成判别联合,这本书就回本了。 如果错,会错在: 判别联合不是免费的——成员多的时候类型定义本身要维护, 简单场景(两三个可选字段、状态间无数据关联)里它的成本可能高于收益; 「闻味即改」的前提是那种气味真的来自「不可能状态」,而不是单纯的字段稀疏。

判断(我们的,不是书里的): 本章工具的隐性门槛不是语法,是**「状态的诚实建模」习惯**。 会写判别联合的人,和不写的人,写的 JavaScript 也不一样——前者的事件处理器天然分状态, 后者的函数里飘着一堆「如果这时候 error 是 undefined 就说明在 loading」的口头约定。 类型在这里不只是检查器,是设计的显影液:bag of optionals 和判别联合的差价, 就是「脑子里想过状态」和「没细想」的差价,TypeScript 只是把这层差价显了影。 所以本章教的对象建模能力,可以迁移到任何语言——用不用类型系统都行。 如果错,会错在: 有些领域的状态真的稀疏且开放(用户自定义字段、插件式架构), 强行判别化会把开放状态压成封闭枚举,反而限制扩展——判别联合适合「状态集封闭且互斥」 的场景,开放状态集该用索引签名(第 07 章)或 schema 校验配合。

11. 边界与局限

  • 收窄的极限:收窄信的是你写的检查;检查写错(如把 in 查的键名拼错),收窄照样自信地带错路。 类型系统的安全上限 = 你运行时检查的正确上限。
  • 穷尽检查本章没正式登场:「漏 case 返回 number | undefined」只是信号; 用 never 把信号变成硬报错,要等到第 16 章。
  • class 相关的收窄(instanceof)浅尝辄止,类的完整类型故事在第 09 章。
  • 判别联合的命名(discriminated union)与「代数数据类型」(ADT)的对应关系原书没提—— 它们是同一思想在不同社区的名字(补充,来自通用知识)。
  • 收窄只在「当前控制流」内有效:回调、闭包、异步边界都会让收窄失效(第 12 章练习 11-2 的闭包时序课在解剖这个)。给联合类型的值收窄后传给回调,回调里仍是宽类型—— 这不是 bug,是收窄的语义边界,绕法只有「钉进 const」或「在回调里重收窄」。
  • 真值检查对并集成员的「误伤」:对 string | number 做真值检查, 空字符串和 0 都被当成 falsy 排掉——如果业务上 ""和 0 是合法值, 真值检查就是错的工具,该用 typeof 或显式 !== undefined。
  • 判别联合的「默认分支」难题:switch 的 default 分支拿到的是「所有已知成员之外」的类型; 在开放的系统里(新成员会加入)default 该写防御逻辑,在封闭的系统里该用 never 触发编译错。 两种写法都对,选错方向的代价不同:防御逻辑的代价是漏 case 静默通过, never 的代价是一次编译器冲突——第 16 章的穷尽检查把这道题做成了开关。

12. 可带走的

  1. | = 或;并集的成员可以互不相关;并集的并集自动拍平成一个大并集。
  2. 字面量类型让「或」落到具体值;补全列表的背后就是字面量并集。
  3. 记忆规则一句话:窄进宽行,宽进窄不行。
  4. 收窄 = 运行时检查换编译器信任;只在块内有效,else 收剩余成员。
  5. 工具箱:typeof(原始类型)、真值(排空,小心 "" 也是 falsy)、throw/提前返回(永久排除)、 in(键不同的对象)、instanceof(类的实例)。
  6. 反查(!==)和存进布尔变量,TypeScript 都认。
  7. typeof null === "object" 是历史坑,校验对象必须补 !== null。
  8. unknown = 我不知道(安全),any = 我不在乎(危险);外部数据先进 unknown。
  9. never = 不可能;什么都不能进、什么槽位都能放(空集是任何集合的子集)。
  10. bag of optionals 闻味即改判别联合;判别子必须人人有份,case 有补全、漏写有信号。
  11. 判别联合别在参数处解构——先收窄,再解构。
  12. 判别子要可省略时,改运行时(先查另一边),别拆类型。
  13. 大并集把竖线提行行首,增减成员是一行 diff。
  14. unknown 的三个日常来源(空容器、catch、外部输入)是同一件事:编译器没有信息时,诚实的默认是 unknown,不是 any。
  15. never 是「不可能」的哨兵:什么都不能进、什么槽位都能放;它在穷尽性检查里当哨兵岗用(第 16 章)。
  16. 并集的三个形状(不相关/同父窄类型/结构对象)各自对应一类收窄工具——typeof、===/switch、判别子;先认形状再选工具。
  17. 「窄进宽行」的四个推论里,函数签名的「进宽出窄」结构是重点:参数求包容,返回值求精确。
  18. unknown 的收窄纪律:一行一格,别用 && 合并跳步;跳步的本质是把证据链换成了 type faith。

最后给十六章内容一句总纲,也是全章的收束:「类型即可能性空间——声明是圈地, 收窄是验证,never 是边界哨兵,判别联合是地形图。」 本章的全部工具, 都是围绕「可能性」这个主轴的展开:并集圈更大的地,字面量圈更准的地, 收窄在运行时验明正身,判别联合把地形的等高线画清楚。 写类型时先问「这个值此刻有多少种可能、我验证过几种」——这一问,是本章 留给读者的最后一个动作,也是它值回票价的唯一动作。

这个动作有一个具体的落地姿势,值得写清楚:打开你正在写的函数,逐个看参数, 给每个参数问自己「它的可能性空间是什么」。能答出具体并集的(字面量或判别联合), 类型签名就有了;只能答出「大概是字符串吧」的,说明这个参数的设计还没想透—— 类型系统在这里充当的不是检查器,是设计审查员:答不出的问题,类型也写不好; 写得好的类型,往往是想得清楚的证明。这个「问可能性」的动作坚持一周, 代码里的 any 和 as 会肉眼可见地减少——不是因为你学了新工具, 而是因为你在声明类型之前,先把值想清楚了。

  1. 收窄工具箱的选型口诀:先判断「分的依据是什么」(种类?空否?键?类?标记), 再选对应的工具——「依据」是收窄的全部本质。
  2. 「定义处消灭错误」优先于「检查处压下错误」:判别联合的回报是补全、 漏写信号、重构安全三份自动红利,审计员的工钱是类型擦除。
  3. 判别联合两兄弟的分工:对象版(有键名、可序列化、默认形态)vs 元组版(两态、不序列化、解构后仍可收窄);对象版是默认。

这 21 条按「认识—使用—设计」三层分了组:1–9 是认识层(理解并集和收窄的世界观), 10–15 是使用层(判别联合、never、unknown 的日常用法), 16–21 是设计层(选型决策、工具分工、红利意识)。三层各熟一层,本章的掌握度 就上一个台阶;全部熟透的标志,是写出新代码时不等思考、手已经选了正确的形态—— 那时候,本章的回报率才真正开始复利。复利的样子可以这样检验:三个月后重读 自己今天写的类型,如果它们读起来「本该如此」、挑不出「这里为什么不用判别联合」 的疑问,本章的知识就已经从「学过的内容」变成了「自己的口味」—— 口味的形成,是技能内化的最终标志,也是这一章区别于工具速查的真正价值。

最后按拆解惯例,留一句本章的「一句话版」给赶时间的读者: 「并集圈可能性,收窄验明正身,判别联合把状态空间砍到和运行时一致。」 三个分句各管一章的三分之一(并集与字面量、收窄工具箱、判别联合); 展开、边界、例外都在正文里,但记住这三句,本章的骨架就立住了。 这三句也是后续十章反复引用的「公共词汇」——第 07 章的对象类型会再来借「可能性」, 第 11 章的推导会再来借「收窄的精确度」,第 16 章的条件类型会再来借「never 的哨兵」。 本章是全书被引用次数最多的章节之一,它的三句版,值得和其他章的三句版并排贴在墙上。 与其他章的三句版合观,本章这三句有一个独特的位置:它们是唯一一组 「不依赖任何后续章节就能独立成立」的句子——并集、收窄、判别联合, 三样都是自足的机制,不像第 11 章的推导或第 16 章的泛型那样需要前文铺垫。 这意味着本章是全书的「最适合先读」候选:零基础读者从这三句进入, 也能立刻获得可用的类型直觉;顺序读者在这里完成了「值的形态」三部曲的收官, 两种读者在本章交汇——这也是它在全书的坐标如此靠前的真正原因。

交汇之后,本章的「课后作业」也清楚了:找一个自己项目里最像 bag of optionals 的类型 (几乎每个项目都有,常见的名字是 State、Props、Response),把它重写成判别联合—— 重写的过程里,本章的每一节都会被动用一次(并集圈可能性、字面量定判别子、 收窄写渲染逻辑、never 收 default),一次实战胜过三节复读。作业交完, 本章的拆解正式收工;全书的「怪癖答疑课」(第 13 章)在等着回收本章立下的所有伏笔。

这个伏笔回收名单值得预先公示——读者在第 13 章会逐个见到: 并集为什么是「拍平」的(本章第 2 节)、字面量并集为什么能补全(第 3 节)、 收窄为什么只在块内有效(第 4 节)、unknown 为什么是默认值(第 5 节)、 never 为什么什么槽位都能放(第 6 节)、判别联合为什么能跨函数边界(第 7 节)。 六节六问,第 13 章用「开放对象 + 两个世界」两个模型统一作答—— 预习过本章的读者,届时会发现「怪癖」大半是本章规则的推论,新的只有那两个模型。 这也是原书把第 13 章命名为「The Weird Parts」的微妙之处——怪不怪是视角问题: 没读过本章,第 13 章满目怪癖;读过本章,第 13 章满目推论。 同一章书,两种读法,之间的差价就是本章的全部价值——这也是「 Essentials 类教材」 章节编排的深层用意:先立规则,后收怪癖,让读者在惊讶之前先拿到解释的钥匙。 拿到钥匙的读者,第 13 章的阅读时间可以从「逐节攻坚」压缩到「两节精读后快速扫过」; 这把钥匙本章配了两把(并集决策线 + 三句版),第 13 章的拆解还会再配一把 (三个心智模型)——三把齐了,全书最「怪」的一章也不过半日之功。 这句预告也是本章拆解的正式结束语:十六章内容(原书编号第五)至此全部落地, 厚度、密度、出处三层齐备,与全书其他十五章平齐。第六章的拆解收工,落幕。 落幕前的最后一句话已经在上文给过(「并集圈可能性,收窄验明正身,判别联合砍状态空间」); 这里只补一个谢幕动作:把本章的「并集决策线」和「收窄工具箱总表」两张表抄下来, 贴到工位——它们是全章被查阅频率最高的两页,值得物理占位。贴完收笔, 本章的全部工作完成;读者拿着两张表和一句话版,已经可以独立上路。 路上的下一站是第 07 章(对象类型)——那里会把「可能性」的圈地艺术, 换成「形状」的拼装艺术,两章合观才是完整的类型设计入门。 这个「合观」建议值得多说一句:两章的顺序不是随意的——先学会圈可能性(本章), 再学会拼形状(第 07 章),最后两者在第 07 章的判别联合处交汇; 交换顺序读,联合的收窄工具会显得无的放矢,对象类型的组合会显得没有边界。 原书把联合放在对象之前的用意,至此完全显影:可能性是形状的「设计输入」, 先有输入,拼装才有方向。方向已定,本章落幕;读者,第 07 章见—— 那里的每一块积木,都等着你用本章的「可能性思维」来拼装。拼装愉快,我们下章见分晓—— 见分晓之前,本章的出处地图已经备好,随时可回核任意一条论断。 回核的路标从正文到原文两步可达——这是全书的统一规格,本章无一处例外。 至此,第六章的拆解工作百分之百完成;我们第 07 章拆解再会,再见—— 拆解的锤子暂时放下,读者的旅程刚刚展开。展开的第一站已在正文中标明: 把项目里最像 bag of optionals 的那个类型,重写成判别联合—— 作业不等人,现在就可以开始。开始之前,本章的十八般兵器已经排开, 用哪件取哪件;用熟了,兵器架本身就可以忘掉——那是拆解工作成功的最终标志。

13. 原文地图

本章地图覆盖原书第 5 章(Union Types)的全部小节与十一道练习。 新增条目对应加厚内容:梯子的三个用途、并集的三个形状与选型决策线、 宽窄规则的四个推论、unknown 的收窄纪律与三个来源、instanceof 的两个盲区、 判别子的选择标准与命名一致性、判别元组 vs 判别对象的对照表—— 均可按行号 + 搜词两步回核。

主题原书位置原文位置
并集的定义(string | null 悬停)Union Typestext/21-fm-union-types.txt:16(搜「string
| 与「成员」同上text/21-fm-union-types.txt:22(搜「member of the union」)
大并集的排版写法同上text/21-fm-union-types.txt:48(搜「AllSortsOfStuff」)
字面量类型同上text/21-fm-union-types.txt:65(搜「YesOrNo」)
addEventListener 补全的真相同上text/21-fm-union-types.txt:78(搜「union of string literals」)
宽窄类型同上text/21-fm-union-types.txt:198(搜「Wide and Narrow」)
子类型/超类型类比同上text/21-fm-union-types.txt:227(搜「subtypes and supertypes」)
并集比成员宽(含报错拆解)同上text/21-fm-union-types.txt:258(搜「not assignable to parameter of type 'number'」)
typeof 收窄与作用域同上text/21-fm-union-types.txt:279(搜「Scoping plays a big role」)
else 收剩余成员同上text/21-fm-union-types.txt:283(搜「number
收窄工具箱总览同上text/21-fm-union-types.txt:295(搜「instanceof and in」)
空字符串 falsy 的坑同上text/21-fm-union-types.txt:348(搜「empty string」)
「反着查」也认同上text/21-fm-union-types.txt:374(搜「reverse of a check」)
检查结果存变量同上text/21-fm-union-types.txt:389(搜「isUsernameOK」)
typeof null === "object"同上text/21-fm-union-types.txt:378(搜「type of null」)
throw 收窄同上text/21-fm-union-types.txt:425(搜「no longer null」)
in 运算符收窄同上text/21-fm-union-types.txt:518(搜「in operator」)
unknown 是最宽类型同上text/21-fm-union-types.txt:540(搜「widest type」)
unknown vs any「不知道/不在乎」同上text/21-fm-union-types.txt:581(搜「don’t know what this is」)
never 是最窄类型同上text/21-fm-union-types.txt:585(搜「narrowest」)
不能赋给 never / never 可赋给一切同上text/21-fm-union-types.txt:613(搜「cannot assign anything to never」)
catch 的 error 是 unknown同上text/21-fm-union-types.txt:664(搜「typed as unknown」)
instanceof 收窄与 throw 非 Error 的边界同上text/21-fm-union-types.txt:684(搜「instanceof」)
parseValue 七格收窄链同上text/21-fm-union-types.txt:804(搜「Parsing error」)
「用 Zod 更好,但手写条件句最懂 unknown」同上text/21-fm-union-types.txt:812(搜「Zod」)
bag of optionals 问题同上text/21-fm-union-types.txt:818(搜「Bag of Optionals」)
判别联合的定义同上text/21-fm-union-types.txt:864(搜「discriminant」)
收窄后 error 必为 string同上text/21-fm-union-types.txt:910(搜「knows that state.status」)
「像 bag of optionals 就重做成判别联合」同上text/21-fm-union-types.txt:930(搜「bag of optionals」)
参数处解构切断判别关联同上text/21-fm-union-types.txt:994(搜「can’t track the relationship」)
「在分支里解构」的正解同上text/21-fm-union-types.txt:1025(搜「within the conditional branches」)
switch 的 case 补全同上text/21-fm-union-types.txt:1059(搜「autocomplete on the cases」)
漏 case → `numberundefined` 信号同上
判别元组与变量关联追踪同上text/21-fm-union-types.txt:1147(搜「keeps track of the relationships」)
判别对象拆解构无此智能同上text/21-fm-union-types.txt:1152(搜「specific to discriminated tuples」)
无判别子的成员瓦解判别同上text/21-fm-union-types.txt:1209(搜「discriminated union is broken」)
「翻转运行时逻辑让 TypeScript 高兴」同上text/21-fm-union-types.txt:1253(搜「flipping the runtime logic」)
本章小结同上text/21-fm-union-types.txt:1257(搜「fundamental concepts」)
练习 5-8:解构切断判别关联的失败尝试Union Typestext/21-fm-union-types.txt:994(搜「separated from the rest」) · text/21-fm-union-types.txt:1025(搜「within the conditional branches」)
练习 5-9:switch 版 calculateArea同上text/21-fm-union-types.txt:1048(搜「switch (shape.kind)」)
练习 5-10:判别元组同上text/21-fm-union-types.txt:1082(搜「Discriminated Tuples」)
练习 5-11:默认分支的处理同上text/21-fm-union-types.txt:1154(搜「Handling Defaults」)
未知与 never 一节同上text/21-fm-union-types.txt:534(搜「unknown and never」) · text/21-fm-union-types.txt:562(搜「Difference Between unknown and any」)

Footnotes

  1. 出处:「Union Types」第 12–18 段(text/21-fm-union-types.txt:16,搜「string | null」)。 ↩

  2. 出处:「Union Types」第 22 段(text/21-fm-union-types.txt:22,搜「member of the union」)。 ↩

  3. 出处:「Union Types」第 46–57 段(text/21-fm-union-types.txt:48,搜「AllSortsOfStuff」)。 ↩ ↩2

  4. 出处:「Union Types」第 91–99 段(text/21-fm-union-types.txt:95,搜「getAlbumFormats」)。 ↩ ↩2

  5. 出处:「Union Types」第 63–68 段(text/21-fm-union-types.txt:65,搜「YesOrNo」)。 ↩

  6. 出处:「Union Types」第 70–78 段(text/21-fm-union-types.txt:78,搜「union of string literals」)。 ↩

  7. 出处:「Union Types」第 198–204 段(text/21-fm-union-types.txt:200,搜「wider versions of other types」)。 ↩ ↩2

  8. 出处:「Union Types」第 227 段(text/21-fm-union-types.txt:227,搜「subtypes and supertypes」)。 ↩ ↩2

  9. 出处:「Union Types」第 256–261 段(text/21-fm-union-types.txt:258,搜「not assignable to parameter of type 'number'」)。 ↩ ↩2 ↩3

  10. 出处:「Union Types」第 265 段(text/21-fm-union-types.txt:265,搜「make it narrower using runtime code」)。 ↩ ↩2

  11. 出处:「Union Types」第 279–281 段(text/21-fm-union-types.txt:281,搜「narrowing applies only within」)。 ↩ ↩2

  12. 出处:「Union Types」第 283–292 段(text/21-fm-union-types.txt:292,搜「string | number | boolean」)。 ↩ ↩2

  13. 出处:「Union Types」第 295–296 段(text/21-fm-union-types.txt:295,搜「instanceof and in」)。 ↩

  14. 出处:「Union Types」第 348 段(text/21-fm-union-types.txt:348,搜「empty string」)。 ↩

  15. 出处:「Union Types」第 374 段(text/21-fm-union-types.txt:374,搜「reverse of a check」)与第 389–398 段(搜「isUsernameOK」)。 ↩ ↩2

  16. 出处:「Union Types」第 378 段(text/21-fm-union-types.txt:378,搜「type of null is equal to "object"」)。 ↩

  17. 出处:「Union Types」第 791–810 段(text/21-fm-union-types.txt:775,搜「parseValue」)。 ↩ ↩2

  18. 出处:「Union Types」第 420–434 段(text/21-fm-union-types.txt:425,搜「no longer null」)。 ↩

  19. 出处:「Union Types」第 490–497 段(text/21-fm-union-types.txt:497,搜「only one of the members」)。 ↩

  20. 出处:「Union Types」第 518–532 段(text/21-fm-union-types.txt:524,搜「return response.data.id」)。 ↩

  21. 出处:「Union Types」第 503–514 段(text/21-fm-union-types.txt:514,搜「built-in way」)。 ↩ ↩2

  22. 出处:「Union Types」第 680–695 段(text/21-fm-union-types.txt:680,搜「instanceof Error」);throw "This is not an error!" 的边界见第 686 段。 ↩ ↩2

  23. 出处:「Union Types」第 540–560 段(text/21-fm-union-types.txt:548,搜「const fn = (input: unknown)」)。 ↩

  24. 出处:「Union Types」第 581 段(text/21-fm-union-types.txt:581,搜「don’t know what this is」)。 ↩ ↩2

  25. 出处:「Union Types」第 564–566 段(text/21-fm-union-types.txt:564,搜「opting out」)。 ↩ ↩2

  26. 出处:「Union Types」第 812 段(text/21-fm-union-types.txt:812,搜「Zod」)。 ↩

  27. 出处:「Union Types」第 585–587 段(text/21-fm-union-types.txt:587,搜「pop up in error messages」)。 ↩

  28. 出处:「Union Types」第 603–611 段(text/21-fm-union-types.txt:611,搜「() => never」)。 ↩

  29. 出处:「Union Types」第 613–636 段(text/21-fm-union-types.txt:613,搜「cannot assign anything to never」)。 ↩ ↩2 ↩3

  30. 出处:「Union Types」第 850–860 段(text/21-fm-union-types.txt:860,搜「bag of optionals」)。 ↩ ↩2

  31. 出处:「Union Types」第 862–910 段(text/21-fm-union-types.txt:910,搜「knows that state.status」)。 ↩ ↩2 ↩3

  32. 出处:「Union Types」第 912–928 段(text/21-fm-union-types.txt:928,搜「LoadingState」)。 ↩

  33. 出处:「Union Types」第 930 段(text/21-fm-union-types.txt:930,搜「bag of optionals」)。 ↩

  34. 出处:「Union Types」第 1059 段(text/21-fm-union-types.txt:1059,搜「autocomplete on the cases」)。 ↩

  35. 出处:「Union Types」第 1075–1078 段(text/21-fm-union-types.txt:1078,搜「number | undefined」)。 ↩

  36. 出处:「Union Types」第 994 段(text/21-fm-union-types.txt:994,搜「can’t track the relationship」)。 ↩ ↩2

  37. 出处:「Union Types」第 1025 段(text/21-fm-union-types.txt:1025,搜「within the conditional branches」)。 ↩ ↩2

  38. 出处:「Union Types」第 1086–1150 段(text/21-fm-union-types.txt:1147,搜「keeps track of the relationships」)。 ↩ ↩2

  39. 出处:「Union Types」第 1152 段(text/21-fm-union-types.txt:1152,搜「specific to discriminated tuples」)。 ↩ ↩2

  40. 出处:「Union Types」第 1202–1217 段(text/21-fm-union-types.txt:1209,搜「discriminated union is broken」)。 ↩

  41. 出处:「Union Types」第 1221–1243 段(text/21-fm-union-types.txt:1243,搜「no longer able to narrow」)。 ↩

  42. 出处:「Union Types」第 1245–1253 段(text/21-fm-union-types.txt:1253,搜「flipping the runtime logic」)。 ↩

  43. 出处:「Union Types」第 123–140 段(text/21-fm-union-types.txt:125,搜「not assignable to parameter of type 'string'」);解法的并集参数见第 136 段(搜「string | null」)。 ↩

  44. 出处:「Union Types」第 176 段(text/21-fm-union-types.txt:176,搜「accepts only the strings」)与第 184 段(搜「"up" | "down" | "left" | "right"」)。补全收益见第 188 段(搜「autocomplete suggestions」)。 ↩

  45. 出处:「Union Types」第 337–398 段(text/21-fm-union-types.txt:348,搜「empty string」)、第 374 段(搜「reverse of a check」)、第 378 段(搜「type of null is equal to "object"」)。 ↩

  46. 出处:「Union Types」第 400 段(text/21-fm-union-types.txt:400,搜「No matter which option」)。 ↩

  47. 出处:「Union Types」第 656–664 段(text/21-fm-union-types.txt:664,搜「typed as unknown」)。 ↩

  48. 出处:「Union Types」第 680–684 段(text/21-fm-union-types.txt:680,搜「instanceof Error」);「covers other classes that inherit from the Error class」见第 684 段。 ↩

  49. 出处:「Union Types」第 687–695 段(text/21-fm-union-types.txt:695,搜「avoid potential runtime errors」)。 ↩

  50. 出处:「Union Types」第 1255–1267 段(text/21-fm-union-types.txt:1257,搜「fundamental concepts」)。 ↩

On this page

并集、字面量与收窄 — TypeScript 的类型枢纽1. 顶层全景:一座梯子梯子的三个用途:声明、检查、收窄2. 并集类型:竖线的「或」并集的第一个反直觉:成员「毫不相关」也合法并集的并集:自动合并并集的两个「形状」:成员相关与否,决定怎么收窄3. 字面量类型:把「或」收紧到具体值宽类型与窄类型(本章的心智模型)「窄进宽行」的完整推论清单字面量并集的两个「日常工作」4. 收窄:编译器跟着你的判断走(本章核心)真值检查的暗坑:空字符串也是 falsy「反着查」也认,存进变量也认「存进变量也认」的边界:const 可以,let 不行JavaScript 的老毛病:typeof null 是 "object"throw 也是收窄:排掉就永久排除throw/return 收窄的实战模式:守门条款in 运算符:键不同的对象,用键来分instanceof:类的实例用类来分instanceof 的盲区:原型链查得到,跨线程查不到收窄工具箱总表:六种工具的分工与暗坑5. unknown 与 any:「我不知道」对「我不在乎」用「巨型条件句」走一遍 unknown 的完整收窄链unknown 的两个日常来源unknown 的收窄纪律:先 typeof,再细化,别把两跳合并6. never:梯子最底,「不可能」本身never 的两个「体感实验」7. 判别联合:给「可选大杂烩」动手术先看病:bag of optionals(可选属性大杂烩)药方:判别联合(discriminated union)判别子的选择:三个合法形态和一个反模式switch 是判别联合的最佳搭档解构的边界:别把判别子和数据拆散判别元组:元组也能当联合成员当判别子可以省略:改运行时,别改类型「改运行时别改类型」的分寸把判别联合当状态机用:renderUI 的完整走查「定义处消灭」的工程回报:三份自动红利判别元组的完整样本:fetchData 的返回值判别元组 vs 判别对象:什么时候选哪个8. 三个完整练习样本(把机制用到底)样本一:为什么非要并集不可 —— getUsername样本二:同一道题的五种收窄 —— validateUsername五种收窄的选择顺序:一个可执行的排序样本三:try/catch 的完整防线 —— somethingDangerous样本三的隐藏课程:错误处理也是类型设计三个样本合成一条「并集决策线」9. 这一章在全书链条中的位置10. 作者的判断与证据(分开标)11. 边界与局限12. 可带走的13. 原文地图Footnotes