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 都有一个公共字段(通常叫
type或kind),取值是互不相同的具体字符串——这就是「类型栏」; - 编译器扮演审核人:你检查了类型栏的值,它才允许你访问对应成员的专属字段。
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 相同):
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 展开;本章会用到它最重要的一种:按辨识字段收窄。
字面量类型:把「任意字符串」收紧成「就这几个」
它是什么:在类型的位置写一个具体的值,比如 "text",就得到一个字面量类型(Literal Type)——只有 "text" 这一个值属于它。2.1 末尾 Pi 源码里 type: "image" 那行,用的正是它。
单独用一个字面量类型没什么意义(变量永远只能装同一个值)。它和联合类型合体才显出价值:
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 是六个字符串字面量的联合(本章后文见真身)。
可辨识联合:type 字段决定其余字段
两块积木齐了,拼出本章主角。
最小例子——两种几何形状:
interface Circle {
kind: "circle";
radius: number;
}
interface Rect {
kind: "rect";
width: number;
height: number;
}
type Shape = Circle | Rect;Circle 和 Rect 都有 kind,取值一个是 "circle"、一个是 "rect",互不相同——这就是辨识字段。消费它的标准姿势是 switch:
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",而联合里只有Circle的kind能等于它」,于是shape在该分支内收窄为Circle,radius随便用;在分支外或错误的分支里访问radius,直接编译报错。- 公共字段随时可用,专属字段收窄后可用:
shape.kind在哪都能访问(所有成员都有),radius、width只在对应分支里存在。 default分支的never:never是「不存在的值」的类型——空集,没有任何值属于它。既然"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 章我们在这里见过 Usage 和 ImageContent,这次看它真正的主角:内容块联合。这是全书第一次完整展示 Pi 的核心数据结构,值得逐行读。
三种内容块
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 可选字段)
}TextContent对照本章模式逐行看:
- 每个 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,模型一次回复的完整结构:
export interface AssistantMessage {
role: "assistant";
content: (TextContent | ThinkingContent | ToolCall)[];
// …(省略:api、provider、model 等来源信息字段)
usage: Usage;
stopReason: StopReason;
// …(省略:errorMessage 等可选字段与时间戳)
}AssistantMessagecontent 的类型 (TextContent | ThinkingContent | ToolCall)[] 一行说清了 Pi 对「模型回复」的建模:一个数组,每个元素是三种内容块之一。配套实验第 4 部分的 render 函数处理的正是这个结构的简化版:switch (block.type) 三个 case,加 never 兜底。
再看第一行 role: "assistant"——又一个字面量类型字段。它不是巧合:AssistantMessage 自己也是一个更大的可辨识联合的成员,辨识字段是 role:
export type Message = UserMessage | AssistantMessage | ToolResultMessage;Message同一个模式在两层同时出现:消息层用 role 辨识(用户 / 模型 / 工具结果),内容块层用 type 辨识(文本 / 思考 / 工具调用)。会话历史、上下文这些概念如何用 Message[] 承载,是 3.2 的主线。顺带一提,上文 stopReason 字段的类型 StopReason(第 391 行)是 "pending" | "stop" | "length" | "toolUse" | "error" | "aborted" 六个字面量的纯联合——不带对象形状的字面量联合在 Pi 里同样随处可见。
流式事件:可辨识联合的火力全开
内容块联合只有三个成员,switch 三下就穷尽了。同一文件里还有一个 12 个成员的大家伙——流式输出(模型边生成边推送,3.3 详讲)的事件类型:
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 };AssistantMessageEvent三个新看点:
- 成员可以不起名字:这里的成员是直接写在
|之间的对象类型字面量(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,脑中应立刻浮现这张图。
实践任务
labs/typescript-basics/06-unions目标:跑通本章全部四组示例,然后亲手触发字面量类型拦截(TS2345)和 never 穷尽检查(TS2322),最后按 Pi 的真实模式给内容块联合加一个新成员。实验目录:labs/typescript-basics/06-unions(实验索引见实践任务索引)。
步骤:
进入实验目录,安装并运行:
shcd labs/typescript-basics/06-unions npm install npm start对照输出与
expected-output.txt是否完全一致(四个部分、共十六行,含分隔空行)。取消
src/main.ts里log("warning", …)那行的注释,运行npm run check(即tsc --noEmit),观察报错;看完重新注释回去。按 README「动手改一改」给
Shape增加Triangle成员(先不要改area),运行npm run check观察never兜底报错;然后补上case "triangle"修复,并往shapes数组加一个三角形验证。进阶:模仿 Pi 给第 4 部分的
ContentBlock加一个ImageContent成员(type: "image"、data: string、mimeType: string,即 2.1 见过的真实形状)。npm run check会在render的never兜底处报错,逼你补上case "image"——体会「编译器替你记得每一处要改的地方」。
预期现象:第 2 步最后三行依次是思考、正文、工具调用的渲染结果。第 3 步真实报错:
src/main.ts(39,5): error TS2345: Argument of type '"warning"' is not
assignable to parameter of type 'Level'.第 4 步在 area 的 default 分支处报错(作者机器上位于第 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观察; - 把
Triangle的kind写成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 的三层实证:消息联合
Message按role辨识;内容块联合按type辨识;12 个成员的流式事件联合AssistantMessageEvent同样按type辨识。
关键术语:联合类型(Union Type)、字面量类型(Literal Type)、可辨识联合(Discriminated Union)、辨识字段(Discriminant)、类型收窄(Narrowing)、穷尽检查(Exhaustiveness Check)、never
关键源码索引:packages/ai/src/types.ts 的 TextContent / ThinkingContent / ToolCall(338–366 行)、AssistantMessage(399–413 行)、Message(433 行)、StopReason(391 行)、AssistantMessageEvent(501–513 行)
自测问题:
type Level = "debug" | "info" | "error"与level: string相比,把哪类错误从运行时提前到了编译期?各举一个会被拦下的调用。- 可辨识联合的成员必须满足哪两个条件,
switch收窄才能工作?kind: string破坏了其中哪一个? default分支里const impossible: never = shape;平时什么也不做,什么情况下它会突然报错?报错传递的信息是什么?- 只看本章摘录:
AssistantMessage.content数组的元素有哪三种可能?拿到一个元素后,你检查哪个字段、用什么语句就能安全访问它的专属字段?
下一章预告:2.4 泛型、class 与类型收窄——本章多次绕开的尖括号(Record<string, any>、Extract<…>)正式登场:泛型让类型也能接受「参数」;同时把本章只用到一角的类型收窄手段(typeof、in、instanceof、自定义类型守卫)一次讲全。