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 正式化
typeof x窄化:typeof是 JS 运算符,返回一个字符串,取值只有固定几个:"string"、"number"、"boolean"、"bigint"、"symbol"、"undefined"、"object"、"function"。编译器只认这几个字面量的相等判断。注意typeof null === "object"(JS 的历史遗留坑),所以判null要用x === null而不是typeof。instanceof C窄化:判断对象是否是类C(或其子类)的实例,原理是查原型链。它只对类有效,对interface/type这种编译期形状无效(它们运行时不存在,见interface 与 type)。- truthiness 窄化:
if (x)会把 falsy 值(null、undefined、0、""、false、NaN)排除。所以if (x)能把string | undefined窄成string——但小心,它也会把合法的0或""一起排除,需要精确判null/undefined时应写if (x != null)。 - 窄化在
if、switch、三元?:、以及&&/||短路里都生效。
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 洞察
typeof null === "object"是 JS 最著名的坑之一:想判空要用=== null/== null,别指望typeof。instanceof只认类;想判一个满足某interface的普通对象是不是"某种形状",instanceof无能为力,得用下一章的自定义守卫或in运算符。- truthiness 窄化很方便但会误伤
0和"":处理数字/字符串字段时,if (x != null)比if (x)更安全。
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 正式化
- 谓词签名:形如
function f(x: Wide): x is Narrow,其中Narrow必须是Wide的子类型。函数体必须返回boolean;返回true时编译器信任你——它不验证函数体逻辑真的和Narrow一致,写错了它也照单全收(这是"你对编译器的承诺")。 - 参数位置:谓词里被窄化的必须是一个具名参数(
x is T里的x)。也可以窄化this:this is T(2.4 会看到 pi 用交叉类型Component & Focusable的例子)。 in运算符:"prop" in obj判对象有没有某属性,本身也是一种内置窄化信号,常用来实现谓词函数体。- 在
.filter()里的妙用:arr.filter((x): x is T => ...)的返回类型是T[]——普通filter无法改变元素类型(过滤undefined后类型还是(string|undefined)[]),但带谓词的 filter 能把undefined从元素类型里真正去掉。
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 洞察
- 谓词函数是"你对编译器的承诺":函数体写错了(比如判反了),编译器不会帮你抓,后续代码会带着错误的类型假设跑。守卫函数体要写得简单可靠。
- 优先用内置窄化(
typeof/instanceof/in);只有当"判断逻辑要复用"或"要判复杂形状"时,才封装成x is T谓词。 .filter(Boolean)在 TS 里不能去掉undefined类型(结果类型不变),要去类型得用.filter((x): x is T => x != null)——这是新手常踩的坑。
3. unknown 与消化不可信的 LLM 数据
3.1 直觉
TS 有两个"任意类型":any 和 unknown。any 是逃生舱——把它赋给谁都行、对它做任何操作编译器都不管,等于关掉了类型检查。unknown 是它类型安全的孪生兄弟:任何值都能赋给 unknown(和 any 一样宽进),但反过来,你不先窄化就什么都不能用它做(严出)——不能调方法、不能读属性、不能赋给别的具体类型。这逼着你在用之前先证明它是什么。处理来自外部世界的数据——尤其是 LLM 吐回来的工具参数、JSON.parse 的结果、网络响应——unknown 是唯一正确的入口类型。
Python 对照:
unknownvsAny的区别,约等于"强制先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 正式化
- 赋值方向:
unknown宽进严出——const u: unknown = anything(任何值都能进);但const s: string = u报错,必须先窄化。any则双向都放行,是类型系统里的"黑洞"。 - 能对
unknown做什么(不窄化):只有极少数与类型无关的操作——===/!==比较、typeof、放进模板字符串(会调String())。读属性、调方法、算术,一律要先窄化。 - 窄化手段:第 1、2 章那套全都适用——
typeof、instanceof、in、自定义x is T谓词。工程上处理复杂形状时,更常见的是交给一个运行时 schema 校验器(typebox / zod / Pydantic 之流),校验通过后把unknown断言成静态类型。 unknown是 top type:所有类型都是它的子类型,所以任何值都能赋给它;它自己只能赋给unknown和any。这与never(bottom type,没有任何值)正好是类型格的两个极点——unknown装得下一切但用不了,never用得了但装不下任何东西。unknown联合会被吸收:unknown | string化简成unknown(既然什么都能是,再多说一种也没意义);这也提醒你——一旦某个字段是unknown,别指望编译器还能替你从旁边的字段推断出什么,窄化只能靠你自己动手。
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 洞察
- 铁律:所有跨越信任边界进来的数据入口类型都应该是
unknown——JSON.parse的结果、catch (e)捕获的异常、网络/LLM 响应。用any接等于把检查关掉,把炸弹留给运行时。 - 简单形状用手写窄化(
typeof/in),复杂或高频形状用 schema 校验器(pi 用 typebox)——别用一堆嵌套if去手校验一个几十字段的对象。 catch (e)里的e在 strict 模式下就是unknown(旧版本是any);想用它先toError(e)归一化,别直接读e.message。
讨论 / Comments
评论托管在本仓库的 GitHub Discussions, 需 GitHub 账号。