TS·03 联合类型与可辨识联合:pi 为什么不用 enum
前两篇里我们把「值和类型」(TS·01)、「形状」(TS·02)讲清楚了。这一篇进入 TypeScript 最有个性、也最能拉开它和 Python 距离的部分:联合类型(union types)。它是把「一个值可能是这几种之一」直接写进类型系统的机制,而 TypeScript 把它用到了极致——一个字符串常量本身就能当类型用,几个这样的常量拼起来就是一个「枚举」。
这不是花活。pi 这个 AI coding-agent 框架整个仓库没有一个 enum,取而代之的全是 "a" | "b" | "c" 这样的字符串字面量联合;它的整套事件系统——LLM 一个 token 一个 token 吐出来的流式事件、agent 的生命周期事件——都是用可辨识联合(discriminated union)建模的。读懂这一篇,你就拿到了读懂 pi 事件循环的钥匙。
三章层层递进:先是字面量联合(为什么没有 enum),它是砖块;再是 as const(把值钉成字面量),它是把砖块砌上去时必须的手法;最后是可辨识联合 + switch 窄化(事件系统的骨架),它把砖块砌成了 pi 的整栋事件大厦。这三者在 pi 的真实代码里几乎总是同时出现,所以放在一篇里讲。
1. 字面量类型与联合:pi 为什么没有一个 enum
1.1 直觉
在 Python 里,「一个参数只能取几个固定值之一」你会用 Enum,或者退一步用 Literal["off", "on"]。TypeScript 走的是后一条路,而且走得更彻底:任何一个字符串常量,本身就是一个类型。"off" 既是一个值,也是一个「只包含 "off" 这一个成员的类型」。把几个这样的字面量类型用 | 拼起来,"off" | "low" | "high",就得到一个「只能是这三者之一」的类型——这就是 TypeScript 版的枚举,不需要任何 enum 关键字。
再往前一步:字面量类型不止字符串。数字 200、布尔 true 也都能当类型用,type HttpOk = 200 | 201 | 204 是合法的。但字符串字面量联合是目前最主流、pi 用得最多的形态,因为它自带可读的「名字」。
Python 对照:
type ThinkingLevel = "off" | "low" | "high"几乎就是ThinkingLevel = Literal["off", "low", "high"]。区别是 TS 里这是语言核心语法,而非typing里的一个特例;而且 TS 编译期会真的拦住你写"medium"。
1.2 最小 demo
// 教学示例 — 非生产代码
// 字符串字面量类型:每个字符串常量都是一个"只有它自己"的类型
type Mode = "off" | "on"; // 联合:值只能是这两个之一
let m: Mode = "on"; // OK
m = "off"; // OK
// m = "auto"; // 编译错误:"auto" 不能赋给 Mode
// 联合也能混不同种类的类型
type Id = number | string; // 值可以是数字,也可以是字符串
const a: Id = 42; // OK
const b: Id = "user-42"; // OK
// 字面量联合当函数参数,调用点就有了"下拉菜单"式的约束
function setMode(x: Mode): void {
console.log(x);
}
setMode("off"); // OK,编辑器还会自动补全 "off" / "on"
type Mode = "off" | "on" 读作:定义一个叫 Mode 的类型别名,它是 "off" 和 "on" 两个字面量类型的联合。变量声明成 Mode 后,赋任何不在这两个之内的字符串都会被编译器拒绝。
1.3 正式化
- 字面量类型:字符串字面量(
"off")、数字字面量(42)、布尔字面量(true)都可以直接当类型。"off"作为类型时,它的唯一合法值就是字符串"off"。 - 联合类型
A | B:表示「A 或 B」。成员可以是任意类型——字面量、interface、number、null,随便混。值属于联合,当且仅当它属于其中至少一个成员。 - 为什么 TS 圈子偏爱字面量联合、而非
enum:enum会编译出运行时代码(一个真实存在的对象),而字面量联合是纯类型,编译后彻底消失(见 TS·00 类型擦除)。- 字面量联合的值就是普通字符串:日志里、JSON 里、
switch里直接可读;enum成员则要Mode.Off这样访问,序列化时还要操心是存名字还是存数字。 - pi 在
tsconfig.base.json里开了erasableSyntaxOnly: true——这个开关禁止一切会生成运行时代码的 TS 专有语法,enum、namespace、参数属性统统被禁。所以 pi 全仓库根本不可能有 enum,这既是团队惯例,也是编译器强制的。
erasableSyntaxOnly 背后是一个更大的趋势:新的 JS 运行时(Node 22+、Deno、Bun)开始支持直接运行 .ts 文件,做法是把类型「剥掉」——但它们只能剥掉「纯类型语法」,遇到 enum 这种「会生成运行时对象」的东西就无能为力。开了这个开关,就等于承诺「我全部代码都能被一剥了之地运行」,enum 自然出局。你在读整个系列时都可以带着这条约束:pi 里凡是「枚举语义」的东西,一定长成字面量联合的样子。
Python 对照:
Literal["off", "on"]和enum.Enum的取舍在 Python 里也存在——Literal轻、直接是字符串值、和 JSON 无缝;Enum重、有命名空间和方法。TS 的字面量联合就是把「用Literal」这一派做成了默认。
1.4 代码引用
pi 用字面量联合的地方多到数不清。看 agent 包的核心类型定义,三个典型的「枚举替代品」:
pi/packages/agent/src/types.ts:L41, L49, L289 — 三个用字面量联合替代 enum 的类型
export type ToolExecutionMode = "sequential" | "parallel";
export type QueueMode = "all" | "one-at-a-time";
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
再看 harness(文件系统抽象层)里两个更「枚举味」的例子——一个描述文件种类,一个是一整套稳定的错误码:
pi/packages/agent/src/harness/types.ts:L108, L111-L121 — FileKind 与 FileErrorCode
export type FileKind = "file" | "directory" | "symlink";
export type FileErrorCode =
| "aborted"
| "not_found"
| "permission_denied"
| "not_directory"
| "is_directory"
| "invalid"
| "not_supported"
| "unknown";
对照:FileErrorCode 换成别的语言几乎必然是个 enum ErrorCode { Aborted, NotFound, ... }。pi 里它就是八个字符串的联合。好处立刻显现:一个 FileError 抛出来,err.code === "not_found" 直接可读、可比较、可以进 JSON 日志,不需要 ErrorCode.NotFound 这层间接;而编译器仍然保证你不会手滑写出 "notfound"(少了下划线)这种拼写错误。ThinkingLevel 七个档位同理——它最终要塞进发给 LLM API 的 JSON,字符串形态天然合适。
还有一层容易被忽略的好处:因为 FileErrorCode 是纯类型,一个下游函数收到 code: FileErrorCode 时,可以在 switch (code) 里让编译器检查「八种错误码是不是都处理到了」。换成 enum,你既失去了「值本身就是可读字符串」的便利,还多背了一个运行时对象——在一个跨 ~30 家 LLM provider、错误码要在进程与日志间来回传递的系统里,前者的可序列化性是刚需。
1.5 洞察
- 默认就用字面量联合,不要找
enum:这是现代 TS 的主流写法,pi 靠erasableSyntaxOnly把它变成了硬约束。你读 pi 时遇到的每一个「枚举」都长这样。 - 字面量联合的值即字符串本身:调试时你看到的是
"not_found"而不是某个枚举对象,序列化零成本——这是它压过enum的最实际理由。 - 联合不限于字面量:
number | string、Foo | null、interface A | interface B都是合法联合;字面量联合只是它最常用的一个特例。下一章的可辨识联合,本质就是「成员为对象、且共享一个字面量 tag 字段」的联合。 - 联合成员越多越要给它起类型别名:pi 把
"off" | ... | "max"命名为ThinkingLevel,而不是在每个用到的地方重复写一长串——别名让签名可读,也让「档位集合」有了单一定义处,改一处即可。
2. as const:把值"钉"成字面量类型
2.1 直觉
上一章说「"off" 是一个字面量类型」,但这里有个坑:TypeScript 在推断变量类型时,默认会放宽(widen)字面量到它的「基础类型」。你写 let x = "text",TS 推断的是 x: string(宽类型),不是 x: "text"(字面量)——因为 let 可变,推成字面量太严了。可一旦你要把这个值塞进 type: "text" | "toolCall" 这样的联合字段,宽的 string 就塞不进去了。as const 就是那句「别放宽,给我钉死成字面量」的指令。
为什么 TS 要默认放宽?因为 let x = "text" 是可变的,你后面很可能想 x = "other"。如果推成字面量 "text",那第二次赋值就非法了,太憋屈。所以 TS 的策略是:可变位置默认放宽,需要严格时你用 as const 显式要回来。
Python 对照:Python 没有这个「推断放宽」问题——
Literal是你显式标注的,不是推断出来的。TS 的as const相当于对着一个字面量说「把它当Literal而不是str」。
2.2 最小 demo
// 教学示例 — 非生产代码
// 目标类型:一个可辨识联合,tag 字段 type 只能是字面量
type Block =
| { type: "text"; text: string }
| { type: "image"; url: string };
// 不加 as const:对象字面量里的 "text" 被推断成宽的 string
const wide = { type: "text", text: "hi" };
// wide 的类型是 { type: string; text: string }
// const b1: Block = wide; // 编译错误:string 不能赋给 "text" | "image"
// 加 as const:type 被钉成字面量 "text",整个对象刚好匹配某一支
const narrow = { type: "text" as const, text: "hi" };
// narrow 的类型是 { type: "text"; text: string }
const b2: Block = narrow; // OK
// 也可以对整个对象加 as const(把每个字段都钉成字面量 + 只读)
const b3 = { type: "image", url: "x" } as const; // { readonly type: "image"; readonly url: "x" }
关键差异就在 type: "text" 和 type: "text" as const 之间:前者推断出 string,后者推断出字面量 "text"。只有后者能通过「这个对象是不是 Block 某一支」的检查。
2.3 正式化
- 字面量放宽(widening):用
let/const声明或作为对象字面量的属性时,TS 默认把"text"推成string、42推成number、true推成boolean。这是为了让「可变的变量后续能被赋别的值」这件事合理。 x as const:一个常量断言(const assertion)。作用于一个字面量或字面量组成的表达式,告诉编译器:- 字符串/数字/布尔字面量保留为字面量类型,不放宽;
- 对象/数组的所有属性变为
readonly,数组变成只读元组。
- 两种粒度:
{ type: "text" as const, text: s }只钉type这一个字段(其余照常推断);{ ... } as const钉整个对象的所有字段。pi 里常用前者——它只需要 tag 字段是字面量,text字段保持普通string即可。 - 它不是类型转换:
as const不改变运行时的值(那还是普通字符串"text"),只影响编译器如何推断类型。运行后as const和它作用的位置一起被擦除。
2.4 代码引用
pi 在把内部消息「归一化」成发给 LLM 的标准格式时,大量构造 { type: "text", text: ... } 这样的内容块。因为目标类型的 type 字段是字面量联合,这里必须用 as const:
pi/packages/agent/src/harness/messages.ts:L133-L152 — 构造内容块时用 as const 钉住 tag 字段
case "custom": {
const content = typeof m.content === "string" ? [{ type: "text" as const, text: m.content }] : m.content;
return {
role: "user",
content,
timestamp: m.timestamp,
};
}
case "branchSummary":
return {
role: "user",
content: [{ type: "text" as const, text: BRANCH_SUMMARY_PREFIX + m.summary + BRANCH_SUMMARY_SUFFIX }],
timestamp: m.timestamp,
};
对照:注意 { type: "text" as const, text: m.content }。如果去掉 as const,这个对象字面量里的 type 会被推成 string,而 content 字段期望的是一个 tag 为字面量 "text" 的内容块联合——string 塞不进去,编译直接报错。as const 精准地只把 type 钉成字面量 "text",而 text 字段仍是普通 string(它本来就该是)。这正是「只钉 tag 字段」这一惯用法的实战样本,而且它在同一个函数里出现了三次(L134/L144/L151),说明这是 pi 里高频的固定套路。
这段代码本身是 pi 的消息归一化逻辑:它把内部形态各异的消息(custom、branchSummary、compactionSummary……又是一个可辨识联合,tag 是 m 的种类)统一转换成 { role, content, timestamp } 这种能直接喂给 LLM 的标准格式。可以看到,构造标准内容块时那个 type: "text" 的 tag 全靠 as const 才对得上类型——第二章的 as const 和第三章的可辨识联合,在这里同时出场。
2.5 洞察
- 看到
type: "x" as const就懂它在干嘛:它在把 tag 字段钉成字面量,好让这个对象能匹配某个可辨识联合的一支——这是 pi 构造事件/消息的标配写法。 - 报错「
stringis not assignable to"text" | ...」时,九成是漏了as const:这是 TS 新手最常撞的墙,根因就是字面量被放宽了。 as const是零成本抽象:它只在编译期起作用,运行时那个值还是朴素字符串,不像enum会留下一个真实对象。- 只钉 tag、不钉整块:pi 用
type: "text" as const而不是给整个对象加as const,因为它只需要 tag 是字面量;整块as const会把text也变成只读字面量,反而束缚了后续赋值。
3. 可辨识联合 + switch 窄化
3.1 直觉
前两章准备的零件,在这里组装成 TypeScript 最强大的一个模式。可辨识联合(discriminated union,也叫 tagged union):一个联合类型,它的每个成员都带一个同名的字面量字段作为「标签」(tag,常叫 type 或 role)。因为每一支的 tag 值互不相同,编译器可以靠「读一下 tag」就判定你手上的值到底是哪一支——这个过程叫窄化(narrowing)。于是 switch (event.type) 之后,每个 case 分支里,event 的类型自动收窄到对应那一支,你可以安全地访问那一支独有的字段。
可辨识联合之所以「可辨识」,就在于那个 tag 字段:不同支的 tag 值互斥,编译器读一眼 tag 就能唯一确定是哪一支。这跟第二章连起来了——正因为 tag 是字面量类型(靠 as const 或类型定义钉住),编译器才知道「type 是 "start"」这件事精确到某一支;如果 tag 被放宽成 string,窄化就失效了。
Python 对照:很像对
msg["type"]做match/case,每个 case 处理一种消息。但有本质区别:Python 的match是运行时行为,访问msg["url"]对不对要跑起来才知道;TS 的窄化是编译期的——进错分支访问了不存在的字段,代码根本编译不过,还能检查你有没有漏掉某一支(穷尽性)。
3.2 最小 demo
// 教学示例 — 非生产代码
// 可辨识联合:每支都有一个字面量 tag 字段 kind,但各带不同的独有字段
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rect"; width: number; height: number };
function area(s: Shape): number {
switch (s.kind) { // 对 tag 字段做 switch
case "circle":
// 这一支里,s 被窄化为 { kind: "circle"; radius: number }
return Math.PI * s.radius ** 2; // 访问 radius 安全;访问 s.width 会编译报错
case "rect":
// 这一支里,s 被窄化为 { kind: "rect"; width; height }
return s.width * s.height;
}
}
console.log(area({ kind: "circle", radius: 2 })); // 12.566...
console.log(area({ kind: "rect", width: 3, height: 4 })); // 12
在 case "circle": 分支里,编译器知道 s.kind 是 "circle",于是把 s 收窄到 circle 那一支——此时 s.radius 合法,而写 s.width 会当场报错(circle 支里没有 width)。这就是「读 tag → 收窄 → 安全访问独有字段」的全过程。
3.3 正式化
- 构成条件:一个联合是「可辨识」的,当且仅当所有成员共享一个字面量类型的公共字段(tag)。
{ kind: "circle" } | { kind: "rect" }可辨识;{ x: number } | { y: number }不可辨识(没有公共字面量字段)。 - 窄化触发点:
switch (v.tag)的每个case、if (v.tag === "circle")的分支内部,编译器都会把v收窄到 tag 值匹配的那一支。这是编译器内建的控制流分析。 - 窄化后的能力:收窄到某一支后,该支独有的字段可安全访问;访问别支才有的字段是编译错误。
- 穷尽性检查(exhaustiveness):当
switch覆盖了联合的所有支,switch之后的v类型收窄为never(不可能的类型)。若之后新增了一支却忘了加case,配合default里的never断言就能让编译器报错提醒——这是可辨识联合相较 Pythonmatch的杀手锏,详见 TS·04 窄化与类型守卫。 - 窄化也能用
if:switch只是最常见的入口;if (v.tag === "circle") { ... }的花括号内部同样会窄化。switch在「支很多」时更整洁,还便于配合上面的穷尽性检查。
3.4 代码引用
pi 的整套流式协议就是可辨识联合。先看 ai 包定义的「LLM 一边生成、一边吐出来」的事件流,tag 字段是 type:
pi/packages/ai/src/types.ts:L460-L473 — AssistantMessageEvent:以 type 为 tag 的可辨识联合
export type AssistantMessageEvent =
| { type: "start"; partial: AssistantMessage }
| { type: "text_start"; contentIndex: number; partial: AssistantMessage }
| { type: "text_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
| { type: "text_end"; contentIndex: number; content: string; partial: AssistantMessage }
| { type: "thinking_start"; contentIndex: number; partial: AssistantMessage }
// ... 省略 thinking_delta / thinking_end / toolcall_* 若干支 ...
| { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall; partial: AssistantMessage }
| { type: "done"; reason: Extract<StopReason, "stop" | "length" | "toolUse">; message: AssistantMessage }
| { type: "error"; reason: Extract<StopReason, "aborted" | "error">; error: AssistantMessage };
上层 agent 运行时再定义一套更粗粒度的生命周期事件 AgentEvent,同样以 type 为 tag,注意每支带的字段各不相同(agent_end 带 messages、tool_execution_start 带 toolCallId/toolName/args):
pi/packages/agent/src/types.ts:L415-L429 — AgentEvent:agent 生命周期的可辨识联合
export type AgentEvent =
// Agent lifecycle
| { type: "agent_start" }
| { type: "agent_end"; messages: AgentMessage[] }
// Turn lifecycle - a turn is one assistant response + any tool calls/results
| { type: "turn_start" }
| { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] }
// Message lifecycle - emitted for user, assistant, and toolResult messages
| { type: "message_start"; message: AgentMessage }
| { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent }
| { type: "message_end"; message: AgentMessage }
// Tool execution lifecycle
| { type: "tool_execution_start"; toolCallId: string; toolName: string; args: any }
// ... tool_execution_update / tool_execution_end ...
最后看这套联合怎么被消费。agent 主循环 for await 从流里逐个取事件,switch (event.type) 分发——这就是 3.1 讲的窄化在真实代码里的样子(for await 是异步迭代,只此带过,详见 TS·07 异步与流式):
pi/packages/agent/src/agent-loop.ts:L319-L339 — for await + switch(event.type) 消费事件流并窄化
for await (const event of response) {
switch (event.type) {
case "start":
partialMessage = event.partial;
context.messages.push(partialMessage);
addedPartial = true;
await emit({ type: "message_start", message: { ...partialMessage } });
break;
case "text_start":
case "text_delta":
case "text_end":
// ... thinking_* / toolcall_* 多支合并处理,归为一类 ...
if (partialMessage) {
partialMessage = event.partial;
context.messages[context.messages.length - 1] = partialMessage;
// ...
}
对照:三段串起来看,可辨识联合的完整链路就清晰了。AssistantMessageEvent / AgentEvent 用 type 字面量当 tag 定义「值可能是这十几种事件之一」;agent-loop.ts 里 switch (event.type) 一进 case "start":,event 就被编译器窄化到 { type: "start"; partial: AssistantMessage } 这一支,于是 event.partial 可以安全访问——如果在 "start" 支里写 event.delta(那是 text_delta 支才有的字段),编译器会当场报错。注意 pi 把 text_start/text_delta/…/toolcall_end 一串 case 堆叠在一起共用一段逻辑:这也是可辨识联合的常见用法——这些支恰好都带 partial 字段,所以合并处理时访问 event.partial 依然类型安全。
3.5 洞察
- 看到
type/role/kind这类字面量字段,就该意识到它是可辨识联合的 tag:pi 的事件、消息、内容块全按这个套路建模,认出 tag 是读懂它们的第一步。顺着 tag 去看每一支带什么字段,就摸清了整个协议。 - 窄化是编译期的、免费的:进错分支访问不存在的字段编译就挂,不用写运行时的
isinstance/assert;这是相较 Pythonmatch的核心优势——match匹配对了也不会帮你检查「这个分支里能不能访问某字段」。 - 多个
case可以堆叠共用逻辑,但只有这些支都拥有的字段才能在合并分支里安全访问——想访问某支独有字段,得把它单独拆成一个case。 - tag 字段必须是字面量类型才能触发窄化:如果 tag 被放宽成了
string(比如漏了as const),switch就丧失窄化能力——这也是三章为什么要连着讲的原因。
讨论 / Comments
评论托管在本仓库的 GitHub Discussions, 需 GitHub 账号。