TS·04 窄化与类型守卫:安全地处理不可信的数据

上一篇(联合与可辨识联合)里,我们学会了用联合类型 A | B | C 描述"这个值可能是好几种形状之一"。但联合只是声明了可能性——真正要用一个 string | number 的值时,你得先知道此刻它到底是哪一种。把宽泛的联合类型,在某个分支里收窄成更具体的单一类型,这个过程就叫窄化(narrowing)。

窄化是 TS 类型系统里最像"魔法"、也最实用的部分:编译器会读你的控制流,在 if、switch、&& 的不同分支里,自动把变量的静态类型改窄。这件事之所以重要,是因为静态类型和运行时的真实数据之间总有一道缝——类型标注是你在编译期的承诺,而外部世界(用户输入、网络响应、LLM 输出)从不遵守你的承诺。窄化就是把"运行时的判断"和"编译期的类型收缩"绑在一起的机制,让你每做一次运行时检查,都换来一次编译器认可的类型收窄。

本篇讲三件事:编译器内置的窄化手段(typeof / instanceof / truthiness),如何用 x is T 谓词函数写自定义窄化器,以及最实战的一招——用 unknown 加类型守卫,安全地消化来自 LLM 和外部世界的不可信数据。这正是 pi 处理模型返回的工具参数、以及归一化各种异常时反复用到的模式,读完本篇你能读懂 pi 的错误处理层和工具校验层。

1. 内置窄化:typeof / instanceof / truthiness

1.1 直觉

窄化的核心洞察是:类型检查不是一次性的,它跟着控制流走。当你写 if (typeof x === "string"),编译器不只是运行时判断了一下,它还记住了——在这个 if 块内部,x 的静态类型就是 string,你可以直接调用 .toUpperCase(),编译器不再报错。TS 内置认识三类"窄化信号":typeof x === "..."(判原始类型)、x instanceof C(判类实例)、以及直接 if (x) 的真值判断(把 null/undefined 排除掉)。

Python 对照:这相当于 Python 的 isinstance(x, str)。但关键差异是——Python 里 isinstance 只影响运行时行为,类型标注(如果你用 mypy)是另一回事;TS 里 typeof/instanceof 会改变后续代码的静态类型,编译器据此放行或拦截你对 x 的调用。

1.2 最小 demo

// 教学示例 — 非生产代码
function describe(x: string | number | null): string {
  if (x === null) {
    return "空值";           // 这里 x: null
  }
  if (typeof x === "string") {
    return x.toUpperCase();  // 这里 x: string,能调字符串方法
  }
  // 走到这里,string 和 null 都被排除了
  return (x + 1).toString(); // 这里 x: number,能做算术
}

class HttpError extends Error {
  constructor(public status: number) { super(); }
}
function report(e: Error) {
  if (e instanceof HttpError) {
    console.log(e.status);   // 这里 e: HttpError,能读 .status
  }
  console.log(e.message);    // 这里 e 又变回 Error
}

注意 describe 走到最后一行时,编译器已经通过前两个 return 推断出剩下的唯一可能是 number——这叫"排除法窄化",不需要你显式写 typeof x === "number"。

1.3 正式化

1.4 代码引用

pi 在把内部消息转成模型可读格式时,custom 类型消息的 content 字段被声明成"要么是纯字符串,要么已经是结构化的内容块数组",一行 typeof 三元就地窄化:

pi/packages/agent/src/harness/messages.ts:L133-L139 — typeof 三元窄化:字符串就包成内容块,否则原样透传

case "custom": {
	const content = typeof m.content === "string" ? [{ type: "text" as const, text: m.content }] : m.content;
	return {
		role: "user",
		content,
		timestamp: m.timestamp,
	};
}

第二个例子更能体现"逐步窄化":pi 把任意抛出来的东西(unknown)归一化成一个 Error 实例。JS 里 throw 可以扔任何值——字符串、数字、甚至对象,所以捕获时你面对的是 unknown,必须一层层排除:

pi/packages/agent/src/harness/types.ts:L30-L38 — toError:先 instanceof 再 typeof,逐步把 unknown 归一化成 Error

export function toError(error: unknown): Error {
	if (error instanceof Error) return error;
	if (typeof error === "string") return new Error(error);
	try {
		return new Error(JSON.stringify(error));
	} catch {
		return new Error(String(error));
	}
}

对照:typeof m.content === "string" ? ... : ... 里,三元的真分支中 m.content 被窄成 string(所以能安全放进 text 字段),假分支里它是内容块数组,原样透传——这就是 1.3 里说的 typeof 窄化。toError 则是排除法的教科书:error instanceof Error 先把 Error 分支拦掉并提前 return,typeof error === "string" 再把字符串拦掉,能走到最后 try 块的,是既非 Error 也非字符串的其它类型。每个 return 都在替后面的代码收窄剩余可能。

1.5 洞察

2. 自定义类型守卫:x is T 谓词函数

2.1 直觉

内置的 typeof/instanceof 只能判原始类型和类。但很多时候你要判的是"这个对象是不是符合某个形状",或者"这个联合里的成员是不是某个特定子类型"——这时需要自定义类型守卫:写一个返回 boolean 的函数,但把它的返回类型标成 arg is T(读作"arg 是 T")。编译器看到这个特殊返回类型,就把这个函数当成一个窄化器:凡是它返回 true 的分支,实参就被窄成 T。

Python 对照:这相当于 Python 3.10+ 的 TypeGuard[T](def is_str_list(x) -> TypeGuard[list[str]])。思路一模一样:一个运行时的布尔检查,同时携带"检查通过后类型是什么"的静态信息。

2.2 最小 demo

// 教学示例 — 非生产代码
interface Cat { meow(): void; }
interface Dog { bark(): void; }

// 返回类型写成 `a is Cat`,而不是 boolean
function isCat(a: Cat | Dog): a is Cat {
  return "meow" in a;   // in 运算符:判对象有没有某个属性
}

function speak(a: Cat | Dog) {
  if (isCat(a)) {
    a.meow();   // 这里 a 被窄成 Cat
  } else {
    a.bark();   // 排除 Cat 后,a 被窄成 Dog
  }
}

// 守卫也能在 filter 里去掉 undefined:
const raw: (string | undefined)[] = ["a", undefined, "b"];
const clean: string[] = raw.filter((s): s is string => s !== undefined);

如果 isCat 的返回类型写成普通的 boolean,if (isCat(a)) 里 a 不会被窄化——编译器不知道这个布尔值和 a 的类型有任何关系。是 a is Cat 这个签名建立了联系。

2.3 正式化

2.4 代码引用

pi 的 Node 文件系统层要把 Node 抛出的错误映射成自己的 FileError。Node 的错误对象带一个 .code 字段(如 "ENOENT"),但 TS 的 Error 类型里没有这个字段,直接读会报错。于是 pi 写了个守卫,把 unknown 窄成带 code 的 NodeJS.ErrnoException:

pi/packages/agent/src/harness/env/nodejs.ts:L77-L86 — isNodeError:谓词把 unknown 窄成带 .code 的 Node 错误

function isNodeError(error: unknown): error is NodeJS.ErrnoException {
	return error instanceof Error && "code" in error;
}

function toFileError(error: unknown, path?: string): FileError {
	if (error instanceof FileError) return error;
	const cause = toError(error);
	if (isNodeError(error)) {
		const message = error.message;
		switch (error.code) {

TUI 包里也有一个典型:组件系统里只有一部分组件是"可聚焦的"(实现了 Focusable 接口),守卫用 in 判断并窄成交叉类型:

pi/packages/tui/src/tui.ts:L109-L112 — isFocusable:in 运算符 + 交叉类型的谓词守卫

/** Type guard to check if a component implements Focusable */
export function isFocusable(component: Component | null): component is Component & Focusable {
	return component !== null && "focused" in component;
}

第三个例子回到上一章那个消息转换函数的收尾——switch 的 default 分支返回了 undefined,所以整个 .map(...) 的结果里混着 undefined,pi 用带谓词的 filter 一次性把它们剔除,并让结果类型变成干净的 Message[]:

pi/packages/agent/src/harness/messages.ts:L163 — filter 里用谓词把 undefined 从元素类型里去掉

		.filter((m): m is Message => m !== undefined);

对照:三段都在实践 2.3 的规则。isNodeError 返回 error is NodeJS.ErrnoException,所以 if (isNodeError(error)) 块里能安全地 switch (error.code)——没有守卫,error 还是 unknown,读 .code 会被编译器拦下。isFocusable 用 "focused" in component 这个 in 窄化写函数体,返回类型是交叉类型 Component & Focusable(既是组件又满足可聚焦),这正是联合与可辨识联合里交叉 & 的用法。最后 .filter((m): m is Message => ...) 把 (Message | undefined)[] 收窄成 Message[],正是 2.3 说的"带谓词的 filter 能改元素类型"。

2.5 洞察

3. unknown 与消化不可信的 LLM 数据

3.1 直觉

TS 有两个"任意类型":any 和 unknown。any 是逃生舱——把它赋给谁都行、对它做任何操作编译器都不管,等于关掉了类型检查。unknown 是它类型安全的孪生兄弟:任何值都能赋给 unknown(和 any 一样宽进),但反过来,你不先窄化就什么都不能用它做(严出)——不能调方法、不能读属性、不能赋给别的具体类型。这逼着你在用之前先证明它是什么。处理来自外部世界的数据——尤其是 LLM 吐回来的工具参数、JSON.parse 的结果、网络响应——unknown 是唯一正确的入口类型。

Python 对照:unknown vs Any 的区别,约等于"强制先 isinstance 判过再用" vs "typing.Any 直接放行"。而"拿 schema 校验外部数据、失败就抛错"这套,近似 Pydantic 的 Model.model_validate(data)——pi 用的是 typebox(见系列开篇提到的:pi 用 typebox 做运行时 schema,类似 Pydantic)。

3.2 最小 demo

// 教学示例 — 非生产代码
function parseConfig(raw: unknown): { retries: number } {
  // raw 是 unknown:下面这些直接用都会被编译器拦下
  // raw.retries          // ✗ 报错:unknown 上没有属性
  // raw.toUpperCase()    // ✗ 报错

  // 必须先窄化,一层层证明形状
  if (typeof raw !== "object" || raw === null) {
    throw new Error("config 必须是对象");
  }
  if (!("retries" in raw) || typeof raw.retries !== "number") {
    throw new Error("retries 必须是数字");
  }
  return { retries: raw.retries };   // 到这里 raw.retries 已被窄成 number
}

// 对比 any:关掉所有检查,埋雷
function bad(raw: any): number {
  return raw.retries.whatever.deeply.nested;  // 编译器一声不吭,运行时炸
}

unknown 版本啰嗦,但每一步都被编译器盯着;any 版本一行搞定,代价是把运行时错误的责任全推给了未来的你。

3.3 正式化

3.4 代码引用

pi 最典型的"消化不可信数据"场景:LLM 返回一个工具调用,参数是模型自由生成的 JSON,完全不可信。pi 不直接信,而是拿每个工具自带的 typebox schema 去校验并强制,校验失败就抛出带详细错误的异常:

pi/packages/ai/src/utils/validation.ts:L278-L310 — validateToolArguments:用 typebox schema 校验 LLM 给的参数,失败即抛错

export function validateToolArguments(tool: Tool, toolCall: ToolCall): any {
	const args = structuredClone(toolCall.arguments);
	Value.Convert(tool.parameters, args);

	const validator = getValidator(tool.parameters);
	// ... (省略:非 typebox schema 的 JSON-Schema 强制转换分支)

	if (validator.Check(args)) {
		return args;
	}

	const errors =
		validator
			.Errors(args)
			.map((error) => `  - ${formatValidationPath(error)}: ${error.message}`)
			.join("\n") || "Unknown validation error";

	const errorMessage = `Validation failed for tool "${toolCall.name}":\n${errors}\n\nReceived arguments:\n${JSON.stringify(toolCall.arguments, null, 2)}`;

	throw new Error(errorMessage);
}

对照:toolCall.arguments 就是 3.1 说的"来自外部世界的不可信数据"——模型生成的参数,形状没有任何编译期保证。pi 没有对它直接 as 断言(那等于自欺),而是走 typebox 的运行时校验:validator.Check(args) 返回 true 才 return args,否则把每条校验错误格式化后 throw。这就是 3.3 里"交给运行时 schema 校验器"的工程化落地——静态类型系统管不到运行时来的数据,唯一可靠的办法是运行时真刀真枪校验一遍。返回类型标成 any 是刻意的:校验通过后,调用方再按工具各自的静态参数类型去用它(这块的 schema→静态类型派生,见泛型里 typebox Static<typeof schema> 的用法)。

3.5 洞察

讨论 / Comments

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