Skip to content

2.3 union、字面量与可辨识联合

本章解决什么问题:上一章末尾两次擦肩而过的 |(「二者之一」)正式登场。真实程序里大量数据天生是「几种形状之一」:一条消息要么来自用户、要么来自模型;一块回复内容要么是文本、要么是一次工具调用请求。本章讲 TypeScript 怎么表达「之一」(联合类型)、怎么把取值钉死到几个具体值(字面量类型),以及两者合体的可辨识联合——Pi 的消息、内容块、流式事件全部建立在这个模式上。读懂它,就读懂了 Pi 一半的类型定义。

前置知识2.1 类型入门(对象类型字面量、类型收窄的初步印象);2.2 interface、type 与函数类型(interface、type 别名)。

学习目标:读完本章后你能

  • 声明并使用联合类型 A | B,解释为什么「用之前必须先收窄」;
  • 用字面量类型把字段取值限制到几个具体值,看懂 "text" | "toolCall" 这样的类型;
  • 亲手写出一个可辨识联合,并用 switch (x.type) 收窄到具体成员;
  • never 兜底做穷尽检查,让「新增成员却忘了处理」变成编译错误;
  • 打开 Pi 的 packages/ai/src/types.ts,读懂内容块与流式事件的真实定义,说出「type 字段决定其余字段」这一模式。

建立直觉

想象单位的报销单。表头有一栏「报销类型」:交通 / 餐饮 / 住宿,三选一。勾了「交通」,下面要填出发地和目的地;勾了「住宿」,要填酒店名和天数。审核人拿到单子,第一眼看类型栏,然后只去核对这个类型对应的那几个格子——不会在一张交通报销单上找酒店名。

可辨识联合就是把这套表单逻辑搬进类型系统:

  • 每种「单子」是一个 interface(2.2 学过的对象形状);
  • 每个 interface 都有一个公共字段(通常叫 typekind),取值是互不相同的具体字符串——这就是「类型栏」;
  • 编译器扮演审核人:你检查了类型栏的值,它才允许你访问对应成员的专属字段。

Pi 每天都在审这种单子:模型的一次回复是一串「内容块」,每块的 type"text""thinking""toolCall" 之一;界面拿到一块内容,先看 type,再决定渲染成正文、思考过程还是工具调用卡片。本章结束时你会读到这三个类型的真实源码。

在到达可辨识联合之前,需要先备齐两块积木:联合类型和字面量类型。

联合类型:值是「几种可能之一」

它是什么:联合类型(Union Type)写作 A | B,竖线读「或」,表示这个值要么是 A 要么是 B。可以串更多:A | B | C。2.1 里 string | null 已经露过面。

为什么需要它:真实数据经常天生多形。一个 ID 字段,老系统里是数字、新系统里是字符串;一个配置项,可以给具体值也可以给 null 表示「用默认」。类型系统必须能说出「这两种都合法,别的都不行」。

没有它会怎样:只剩两条难走的路——用 any 全面放弃检查(2.1 讲过它是逃生门),或者为每种可能各写一个几乎一样的函数。联合类型保住了检查:编译器知道值有几种可能,并且强制你处理每一种

最小例子(与本章配套实验 labs/typescript-basics/06-unions 相同):

ts
type Id = string | number;

function formatId(id: Id): string {
  if (typeof id === "string") {
    // 这个分支里 TypeScript 知道 id 是 string,可以放心调用字符串方法。
    return id.toUpperCase();
  }
  // 走到这里,string 的可能性已被排除,id 只剩 number。
  return `#${id.toFixed(0)}`;
}

console.log(formatId("req-42"));
console.log(formatId(42));

真实输出(来自配套实验):

REQ-42
#42

关键规则:对联合类型的值,只能直接做「所有成员都支持」的操作toUpperCase() 是字符串专属的,number 没有,所以跳过检查直接调用会得到真实报错:

error TS2339: Property 'toUpperCase' does not exist on type 'Id'.
  Property 'toUpperCase' does not exist on type 'number'.

想用专属操作,必须先用检查语句排除其他可能——typeof id === "string" 之后,if 分支里 id 的类型从 string | number 缩小成了 string。这个过程叫类型收窄(Narrowing),2.1 提过一次,系统的收窄手段在 2.4 展开;本章会用到它最重要的一种:按辨识字段收窄。

🌱 初学者提示类型不是被擦除了吗,收窄怎么还能工作?
注意 `typeof id === "string"` 本身是一行普通的 JavaScript,运行时真实执行。收窄不是运行时魔法,而是**编译器顺着你的检查代码做的推理**:「既然这个分支只有 id 是字符串时才进得来,那分支里就把它当字符串」。类型照旧在编译后被擦除(1.1 讲过),留下来的检查语句依然保证运行时走对分支——编译期推理和运行时行为靠同一行代码对齐。

字面量类型:把「任意字符串」收紧成「就这几个」

它是什么:在类型的位置写一个具体的值,比如 "text",就得到一个字面量类型(Literal Type)——只有 "text" 这一个值属于它。2.1 末尾 Pi 源码里 type: "image" 那行,用的正是它。

单独用一个字面量类型没什么意义(变量永远只能装同一个值)。它和联合类型合体才显出价值:

ts
type Level = "debug" | "info" | "error";

function log(level: Level, message: string): void {
  console.log(`[${level}] ${message}`);
}

log("info", "程序启动");
log("error", "有地方出错了");

真实输出(来自配套实验):

[info] 程序启动
[error] 有地方出错了

为什么需要它level 如果标成 string,那 "warning"(不在清单里)、"eror"(拼错了)都能混进来,错误要等运行时才暴露——甚至永远不暴露,只是日志静静地少了一类。收紧成三个字面量的联合后,试试 log("warning", "…"),编译器当场拦下(真实报错):

error TS2345: Argument of type '"warning"' is not assignable
to parameter of type 'Level'.

额外的红利是编辑器补全:光标停在参数位置,三个合法取值直接弹出来,不用翻文档。数字和布尔值同样能做字面量类型,比如 Pi 里表示「模型调用为何停止」的 StopReason 是六个字符串字面量的联合(本章后文见真身)。

⚠️ 常见误解把辨识字段的类型写宽成 string
下一节的模式要求每个成员的公共字段是**互不相同的字面量类型**。初学者常顺手写成 `kind: string`——形状检查依然通过,但编译器再也无法根据 `kind` 的取值区分成员:`switch` 分支里访问专属字段会全线报 TS2339(作者实测:`kind: string` 版本的 Shape 例子,三处字段访问全部报错)。记住:辨识字段的类型必须写具体值,如 `kind: "circle"`。

可辨识联合:type 字段决定其余字段

两块积木齐了,拼出本章主角。

📘 概念可辨识联合(Discriminated Union)
一种联合类型:每个成员都是对象形状,且都带同一个公共字段(叫**辨识字段**,Discriminant),字段类型是互不相同的字面量。检查辨识字段的值,编译器就能把整个对象收窄到对应成员——「type 字段决定其余字段」。也译「区分联合」「标签联合」(Tagged Union)。

最小例子——两种几何形状:

ts
interface Circle {
  kind: "circle";
  radius: number;
}

interface Rect {
  kind: "rect";
  width: number;
  height: number;
}

type Shape = Circle | Rect;

CircleRect 都有 kind,取值一个是 "circle"、一个是 "rect",互不相同——这就是辨识字段。消费它的标准姿势是 switch

ts
function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      // 这里 shape 已被收窄为 Circle,radius 可以直接用。
      return Math.PI * shape.radius ** 2;
    case "rect":
      // 这里 shape 已被收窄为 Rect。
      return shape.width * shape.height;
    default: {
      // 所有成员都处理过了,shape 在这里的类型是 never(不存在的值)。
      const impossible: never = shape;
      throw new Error(`未处理的形状:${JSON.stringify(impossible)}`);
    }
  }
}

真实输出(来自配套实验,圆半径 1、矩形 3×4):

circle 的面积:3.14
rect 的面积:12.00

逐个拆解:

  • switch (shape.kind):对辨识字段分支。进入 case "circle" 时,编译器推理:「kind"circle",而联合里只有 Circlekind 能等于它」,于是 shape 在该分支内收窄为 Circleradius 随便用;在分支外或错误的分支里访问 radius,直接编译报错。
  • 公共字段随时可用,专属字段收窄后可用shape.kind 在哪都能访问(所有成员都有),radiuswidth 只在对应分支里存在。
  • default 分支的 nevernever 是「不存在的值」的类型——空集,没有任何值属于它。既然 "circle""rect" 都被前面的 case 接走了,default 里的 shape 已经无路可走,类型收窄成 never,赋给 never 变量顺利通过。

never 那行看起来什么都没做,却是整个模式的安全网,行话叫穷尽检查(Exhaustiveness Check)。给 Shape 新增一个成员 Triangle不改 area,编译器立刻报错(真实输出,作者机器上位于 default 分支那行赋值处):

error TS2322: Type 'Triangle' is not assignable to type 'never'.

推理过程:Triangle 没有自己的 case,会掉进 default,于是那里的 shape 不再是 never 而是 Triangle——赋值失败。翻译成人话:「你加了新形状,但 area 忘了处理」。没有这行兜底,新成员会在运行时悄悄掉进 default 抛异常(或者更糟:走了不该走的逻辑);有了它,「忘了处理新情况」从运行时 bug 变成编译错误。Pi 的内容块、流式事件都有十几个成员且随版本增长,这道安全网是大型代码库敢频繁加成员的底气。

为什么不用别的办法?对比两条常见替代路线,可辨识联合的价值更清楚:

  • 一个大 interface + 全可选字段{ kind: string; radius?: number; width?: number; … }。谁和谁配套全靠注释和自觉,编译器帮不上忙——kind"circle" 却带着 width 的对象完全合法。
  • class 继承 + instanceof 判断:在纯内存的程序里可行,但 Pi 的消息要存进会话文件、要经由模型 API 在网络上传输,这些场景里流动的是 JSON 纯数据——JSON 里没有 class,只有对象和字段。从源码结构看,Pi 选择可辨识联合正是因为它只依赖数据本身:一个带 type 字段的普通对象,存盘、传输、恢复之后依然能被完整地分类和检查。

图解

图加载中…

图 2.3-1 switch 对可辨识联合的收窄与 never 穷尽检查
阅读顺序:从上往下。实线是正常路径:进入 switch 前 shape 是整个联合,每个 case 分支里被收窄为具体成员,全部成员处理完后 default 里只剩 never。虚线是安全网生效的时刻:新增成员没有对应 case,default 里的类型不再是 never,赋值行当场报错。左下、右下两个终点分别对应实验「动手改一改」里你会亲眼看到的报错与修复。

Pi 中哪里用到了它

打开 packages/ai/src/types.ts——pi-ai 包(Pi 中负责与模型 API 打交道的 package)的类型定义文件。2.1 章我们在这里见过 UsageImageContent,这次看它真正的主角:内容块联合。这是全书第一次完整展示 Pi 的核心数据结构,值得逐行读。

三种内容块

ts
export interface TextContent {
	type: "text";
	text: string;
	// …(省略:textSignature 可选字段)
}

export interface ThinkingContent {
	type: "thinking";
	thinking: string;
	// …(省略:thinkingSignature、redacted 可选字段)
}

// …(省略:ImageContent——2.1 章见过的图片内容块)

export interface ToolCall {
	type: "toolCall";
	id: string;
	name: string;
	arguments: Record<string, any>;
	// …(省略:thoughtSignature 可选字段)
}
earendil-works/pi@c13ffe1第 338–366 行在 GitHub 查看 ↗
三种内容块的定义:正文、思考过程、工具调用请求。每个 interface 的第一行都是字面量类型的 type 字段——辨识字段,取值互不相同。

对照本章模式逐行看:

  • 每个 interface 的第一行都是辨识字段type: "text" / type: "thinking" / type: "toolCall"——和 Shape 例子里的 kind 一模一样,只是字段名换成了 type
  • 专属字段各自不同:文本块带 text;思考块带 thinking(模型的推理过程,3.1 讲它从哪来);工具调用块带 id(本次调用的编号,工具结果要凭它对号入座)、name(调哪个工具)和 arguments(参数)。type 字段决定其余字段,在真实源码里兑现了。
  • arguments: Record<string, any>:读作「键是字符串、值任意的对象」——Record 是内置的泛型工具,2.4 讲泛型。这里用宽类型 any 接住参数,是因为参数由模型生成、形状事先未知,要等运行时按工具各自的参数定义校验(6.4 工具系统的主题)。
  • 被省略的可选字段textSignature 等)都是为特定 Provider(模型服务提供方)保留的附加信息,现在不必深究——注意它们全带 ?,正是 2.2 讲的「有的数据源有、有的没有」。

联合在哪里成形

三个 interface 只是成员,把它们「或」起来的地方在同一文件不远处——AssistantMessage,模型一次回复的完整结构:

ts
export interface AssistantMessage {
	role: "assistant";
	content: (TextContent | ThinkingContent | ToolCall)[];
	// …(省略:api、provider、model 等来源信息字段)
	usage: Usage;
	stopReason: StopReason;
	// …(省略:errorMessage 等可选字段与时间戳)
}
packages/ai/src/types.ts · AssistantMessage
earendil-works/pi@c13ffe1第 399–413 行在 GitHub 查看 ↗
模型一次回复的形状:content 是内容块联合的数组——「一次回复 = 若干块,每块三种之一」。usage 正是 2.1 读过的用量统计。

content 的类型 (TextContent | ThinkingContent | ToolCall)[] 一行说清了 Pi 对「模型回复」的建模:一个数组,每个元素是三种内容块之一。配套实验第 4 部分的 render 函数处理的正是这个结构的简化版:switch (block.type) 三个 case,加 never 兜底。

再看第一行 role: "assistant"——又一个字面量类型字段。它不是巧合:AssistantMessage 自己也是一个更大的可辨识联合的成员,辨识字段是 role

ts
export type Message = UserMessage | AssistantMessage | ToolResultMessage;
earendil-works/pi@c13ffe1第 433 行在 GitHub 查看 ↗
消息联合:用户消息、模型回复、工具结果三选一,辨识字段是 role。会话历史就是 Message 的数组。

同一个模式在两层同时出现:消息层role 辨识(用户 / 模型 / 工具结果),内容块层type 辨识(文本 / 思考 / 工具调用)。会话历史、上下文这些概念如何用 Message[] 承载,是 3.2 的主线。顺带一提,上文 stopReason 字段的类型 StopReason(第 391 行)是 "pending" | "stop" | "length" | "toolUse" | "error" | "aborted" 六个字面量的纯联合——不带对象形状的字面量联合在 Pi 里同样随处可见。

流式事件:可辨识联合的火力全开

内容块联合只有三个成员,switch 三下就穷尽了。同一文件里还有一个 12 个成员的大家伙——流式输出(模型边生成边推送,3.3 详讲)的事件类型:

ts
export type AssistantMessageEvent =
	| { type: "start"; partial: AssistantMessage }
	// …(省略:text_start)
	| { type: "text_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
	// …(省略:text_end 以及 thinking、toolcall 各自的 start、delta、end 共 7 个成员)
	| { type: "done"; reason: Extract<StopReason, "stop" | "length" | "toolUse">; message: AssistantMessage }
	| { type: "error"; reason: Extract<StopReason, "aborted" | "error">; error: AssistantMessage };
packages/ai/src/types.ts · AssistantMessageEvent
earendil-works/pi@c13ffe1第 501–513 行在 GitHub 查看 ↗
流式事件联合的 12 个成员:start 开场,text/thinking/toolcall 三组 start、delta、end 报告增量,done 或 error 收尾。辨识字段依然叫 type。

三个新看点:

  • 成员可以不起名字:这里的成员是直接写在 | 之间的对象类型字面量(2.1 的术语),没有先声明成 interface——成员只在这个联合里出场时,匿名更省事。两种写法效果相同。
  • type 的取值描述「阶段」而非「种类」text_delta 表示「正文又多了一小段」,done 表示「整条回复完成」。界面收到事件后 switch (event.type),该追加字符追加字符、该收尾收尾——这条事件流怎么一路传到终端界面,是 5.4 的主题。
  • Extract<StopReason, …>:从 StopReason 六个字面量里抽取子集的类型工具,暂时把它读作「三选一 / 二选一的字面量联合」即可。它保证了 done 事件的 reason 永远不会是 "error"——连「哪个分支允许哪些取值」都写进了类型。

读到这里可以回答本章开头的承诺了:Pi 的消息(Message,按 role 辨识)、内容块(content 数组元素,按 type 辨识)、流式事件(AssistantMessageEvent,按 type 辨识)——全是可辨识联合。6.1 精读 pi-ai 包时,这些类型会作为老朋友再次出现。

图加载中…

图 2.3-2 Pi 消息结构中的两层可辨识联合
阅读顺序:从上往下。上层是消息联合(packages/ai/src/types.ts:433),三种消息靠 role 字段区分;中间的 AssistantMessage 的 content 字段(同文件第 401 行)是内容块数组,每个元素再靠 type 字段三选一(第 338–366 行)。同一模式嵌套两层——读 Pi 源码时看到 switch 某个对象的 role 或 type,脑中应立刻浮现这张图。

实践任务

🛠 实践任务从 Shape 到 Pi 内容块:亲手触发两次编译期拦截labs/typescript-basics/06-unions

目标:跑通本章全部四组示例,然后亲手触发字面量类型拦截(TS2345)和 never 穷尽检查(TS2322),最后按 Pi 的真实模式给内容块联合加一个新成员。实验目录:labs/typescript-basics/06-unions(实验索引见实践任务索引)。

步骤

  1. 进入实验目录,安装并运行:

    sh
    cd labs/typescript-basics/06-unions
    npm install
    npm start
  2. 对照输出与 expected-output.txt 是否完全一致(四个部分、共十六行,含分隔空行)。

  3. 取消 src/main.tslog("warning", …) 那行的注释,运行 npm run check(即 tsc --noEmit),观察报错;看完重新注释回去。

  4. 按 README「动手改一改」给 Shape 增加 Triangle 成员(先不要area),运行 npm run check 观察 never 兜底报错;然后补上 case "triangle" 修复,并往 shapes 数组加一个三角形验证。

  5. 进阶:模仿 Pi 给第 4 部分的 ContentBlock 加一个 ImageContent 成员(type: "image"data: stringmimeType: string,即 2.1 见过的真实形状)。npm run check 会在 rendernever 兜底处报错,逼你补上 case "image"——体会「编译器替你记得每一处要改的地方」。

预期现象:第 2 步最后三行依次是思考、正文、工具调用的渲染结果。第 3 步真实报错:

src/main.ts(39,5): error TS2345: Argument of type '"warning"' is not
assignable to parameter of type 'Level'.

第 4 步在 areadefault 分支处报错(作者机器上位于第 73 行):

error TS2322: Type 'Triangle' is not assignable to type 'never'.

如何判断成功:所有改动完成后 npm run check 无输出(无输出即通过);npm start 的输出与 expected-output.txt 相比,只多出你新增的两行——第 3 部分末尾的三角形面积、第 4 部分末尾的图片渲染结果;你能不看书对同伴说清「type 字段决定其余字段」和「never 兜底拦住了什么」。

常见错误

  • npm start 找类型错误——tsx 只转换、不检查类型(1.2 讲过),三个报错实验都必须用 npm run check 观察;
  • Trianglekind 写成 kind: string——收窄立刻失效,case 分支里访问 base 会报 TS2339(本章 CommonMistake);
  • 第 5 步加了成员却在 reply 数组里写 { type: "img", … }——辨识字段的取值必须与定义逐字符一致,"img" 不是 "image"

对应源码位置packages/ai/src/types.ts 第 338–366 行(内容块三成员)、第 501–513 行(流式事件联合),见本章 SourceRef。

本章小结

  • 联合类型 A | B 表示「几种可能之一」;对联合值只能直接做所有成员都支持的操作,专属操作必须先收窄。
  • 字面量类型把取值钉死到具体值;与联合合体("debug" | "info" | "error")后,非法取值和拼写错误在编译期被拦截。
  • 可辨识联合 = 每个成员都带同一个字面量类型公共字段(辨识字段)的联合;switch (x.type) 的每个 case 分支里,编译器自动把值收窄为对应成员——type 字段决定其余字段
  • default 分支把值赋给 never 变量即穷尽检查:新增成员忘了处理,编译立刻报 TS2322。
  • 可辨识联合只依赖数据本身(一个带 type 字段的普通对象),存盘、走网络后依然可分类可检查——这是 Pi 全面采用它的原因。
  • Pi 的三层实证:消息联合 Messagerole 辨识;内容块联合按 type 辨识;12 个成员的流式事件联合 AssistantMessageEvent 同样按 type 辨识。

关键术语:联合类型(Union Type)、字面量类型(Literal Type)、可辨识联合(Discriminated Union)、辨识字段(Discriminant)、类型收窄(Narrowing)、穷尽检查(Exhaustiveness Check)、never

关键源码索引packages/ai/src/types.tsTextContent / ThinkingContent / ToolCall(338–366 行)、AssistantMessage(399–413 行)、Message(433 行)、StopReason(391 行)、AssistantMessageEvent(501–513 行)

自测问题

  1. type Level = "debug" | "info" | "error"level: string 相比,把哪类错误从运行时提前到了编译期?各举一个会被拦下的调用。
  2. 可辨识联合的成员必须满足哪两个条件,switch 收窄才能工作?kind: string 破坏了其中哪一个?
  3. default 分支里 const impossible: never = shape; 平时什么也不做,什么情况下它会突然报错?报错传递的信息是什么?
  4. 只看本章摘录:AssistantMessage.content 数组的元素有哪三种可能?拿到一个元素后,你检查哪个字段、用什么语句就能安全访问它的专属字段?

下一章预告2.4 泛型、class 与类型收窄——本章多次绕开的尖括号(Record<string, any>Extract<…>)正式登场:泛型让类型也能接受「参数」;同时把本章只用到一角的类型收窄手段(typeof、in、instanceof、自定义类型守卫)一次讲全。

本书分析的 Pi 版本:earendil-works/pi@c13ffe1(2026-07-30)