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 正式化

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 洞察

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 正式化

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 洞察

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 正式化

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 洞察

讨论 / Comments

评论托管在本仓库的 GitHub Discussions, 需 GitHub 账号。