Skip to content

2.9 错误处理与测试基础

本章解决什么问题:程序总会失败——模型服务返回 500、要读的文件不存在、模型吐出的 JSON 少了个花括号。本章回答两个问题:失败发生时,这个消息怎么传给能处理它的那一层throw / try / catch / finallyError 与自定义错误);以及你怎么知道自己写的失败处理真的有效(自动化测试)。这是第二部分的最后一章,读完你就有了读 Pi 源码所需的全部 TypeScript 基础。

前置知识2.4 泛型、class 与类型收窄(class、instanceofunknown 的收窄流程)、2.5 异步编程:Promise 与 async/awaitawait 处的失败以异常形式出现)。

学习目标:读完本章后你能

  • 说清 throw 为什么是「第二条返回通道」,以及 try / catch / finally 三块各自负责什么;
  • 说出 Error 对象的四个关键字段(name / message / stack / cause),并写出一个带错误码的自定义错误 class;
  • 解释为什么 catch (e) 里的 eunknown,并按固定流程用 instanceof 收窄它;
  • 分辨「被 try/catch 接住的异步失败」和「因为忘了 await 而无人接住的失败」,并说出后者的后果;
  • 用 Node.js 内置的 node:test + node:assert 写出可运行的测试,读懂 describe / it / assert.equal / assert.throws,并说出「测试即文档」是什么意思。

建立直觉:失败需要第二条返回通道

一个函数只有一条返回通道:return。如果失败也必须走这条通道,你只有两个选择,而且都不好。

一是哨兵值:约定用某个特殊返回值代表失败,比如查找失败返回 -1、解析失败返回 null。问题在于哨兵值可以被无声忽略——调用方少写一个 if,程序就带着一个 null 继续往下跑,直到几十行之后才在某个毫不相干的地方崩掉,那时早已看不出真正的原因。

二是把成功和失败都塞进返回值,比如返回 { ok: true, value } | { ok: false, error }(2.4 见过的 Result 类型就是这个思路)。这个办法很稳妥,代价是每一层调用都必须亲手检查并转发:五层深的调用链,中间三层明明什么都不想管,也得逐层写「如果失败就原样返回失败」。

throw 提供的是第三条路,也就是第二条返回通道:函数遇到无法继续的情况时 throw 一个值,这个值不走 return,而是沿着调用栈自动向上冒泡,逐层退出正在执行的函数,直到遇见第一个 catch。中间那些不关心失败的层不需要写任何代码,失败自己会穿过它们。而且如果一路上没有任何人接住,程序会带着完整的错误信息和调用栈直接终止——失败不可能被无声忽略,这是 throw 相对哨兵值最大的价值。

📘 概念异常(Exception)
throw 抛出、沿调用栈向上传播的值。JavaScript 里任何值都能被 throw(字符串、数字都行),但约定俗成只 throw Error 对象——因为只有 Error 携带调用栈信息。后面会看到,「任何值都能被 throw」这条规则正是 catch 里的类型是 unknown 的原因。

try / catch / finally:三块的分工

语法只有三块,职责各不相同:

ts
function half(n: number): number {
  if (n % 2 !== 0) {
    throw new Error(`${n} 不是偶数`);
  }
  return n / 2;
}

for (const n of [8, 7]) {
  try {
    console.log(`try:half(${n}) = ${half(n)}`);
  } catch (e) {
    console.log(`catch:${e instanceof Error ? e.message : String(e)}`);
  } finally {
    console.log(`finally:收拾现场(${n})`);
  }
}

真实输出:

try:half(8) = 4
finally:收拾现场(8)
catch:7 不是偶数
finally:收拾现场(7)
  • try圈出「这段代码可能失败」。注意 n = 7 那一轮,console.log 那行根本没有打印——half(7) 抛出后,try 块里剩下的代码全部被跳过,控制权直接交给 catch。这是 throwreturn 的关键差别:它不是「返回一个坏值继续走」,而是「立刻离开」。
  • catch接住抛出的值并决定怎么办:记日志、换个方案重试、包装成更有意义的错误再抛出去、或者干脆咽下。咽下要慎重——一个空的 catch {} 会让失败彻底消失,是最难查的 bug 来源之一。
  • finally无论 try 成功、catch 接住失败、甚至 tryreturn 了,都一定会执行。它专门用来收拾现场:关文件、清计时器、把「正在运行」的标志位改回 false。上面输出里两次「收拾现场」,一次来自成功、一次来自失败——这正是 finally 存在的理由:把释放资源的代码只写一遍,而不是在成功路径和每个失败路径上各抄一份。

Error:不只是一段文字

Error 是 JavaScript 内置的 class(2.4 讲过 class 在运行时真实存在,所以能用 instanceof 判断)。它有四个字段值得认识:

ts
const err = new Error("读取文件失败", { cause: new Error("ENOENT: no such file") });
console.log(`name    = ${err.name}`);
console.log(`message = ${err.message}`);
console.log(`cause   = ${err.cause instanceof Error ? err.cause.message : "无"}`);
console.log(`stack 第一行 = ${err.stack?.split("\n")[0]}`);

真实输出:

name    = Error
message = 读取文件失败
cause   = ENOENT: no such file
stack 第一行 = Error: 读取文件失败
  • message:给人看的一句话说明。
  • name:错误的种类名,默认 "Error";自定义错误里应该改掉它,这样打印出来能一眼看出是哪类错误。
  • stack:调用栈快照,记录了「错误在哪一行产生、是被谁调用的」。这是 throw new Error(...) 优于 throw "出错了" 的直接原因——字符串没有栈,出了事你只知道结果不知道来路。
  • cause底层原因。构造函数第二个参数写 { cause: 原始错误 },就能把「更底层那个真正的错误」挂在上面。它解决一个常见矛盾:外层想换上一句对调用方友好的说明,又不想丢掉底层的技术细节——cause 让两者同时保留。这个字段在 Pi 源码里用得非常多。

自定义错误:让调用方能分辨失败的种类

catch 接住错误之后,第一件事往往是判断「是哪一种失败」——因为不同的失败要用不同的办法处理:网络超时可以重试,参数写错了重试一万次也没用。

那怎么分辨?最容易想到、也最不该用的办法是比对 message 文本:if (e.message.includes("超时"))。这是把「给人看的说明」当成了「给代码看的接口」——哪天有人把提示语从「超时」改成「请求超时」,所有依赖它的判断就全部静默失效,而且没有任何编译错误提醒你。

正确做法是定义自己的错误 class,并挂一个稳定的错误码字段

ts
type ToolCallErrorCode = "invalid_json" | "missing_name";

class ToolCallError extends Error {
  public code: ToolCallErrorCode;

  constructor(code: ToolCallErrorCode, message: string, cause?: Error) {
    super(message, cause === undefined ? undefined : { cause });
    this.name = "ToolCallError";
    this.code = code;
  }
}

try {
  throw new ToolCallError("missing_name", "缺少 name 字段");
} catch (e) {
  if (e instanceof ToolCallError) {
    console.log(`收到 ${e.name},错误码 ${e.code}:${e.message}`);
  } else if (e instanceof Error) {
    console.log(`其它 Error:${e.message}`);
  } else {
    console.log(`非 Error 的抛出值:${String(e)}`);
  }
}

真实输出:

收到 ToolCallError,错误码 missing_name:缺少 name 字段

三个要点。extends Error(2.4 的 class 继承)让它仍然是一个 Errorinstanceof Error 照样成立,stack 照样有——你只是在标准错误上加东西,没有另起炉灶。super(...)messagecause 交给基类处理。code 是字面量 union(2.3 讲的可辨识联合思路):取值就那么几个,写错一个字母是编译错误,而且 switch (e.code) 时编译器还能帮你检查有没有漏掉分支。message 随时可以润色,code 是承诺不变的接口——这个分工是错误设计的核心。

catch 里的 unknown:为什么编译器不让你直接用

试试在 catch 里直接读属性:

ts
try {
  JSON.parse("{ 坏掉的 JSON");
} catch (e) {
  console.log(e.message);
}

tsc --noEmit --strict 的真实报错:

d.ts(4,15): error TS18046: 'e' is of type 'unknown'.

原因回到本章开头那句话:JavaScript 允许 throw 任何值。一个第三方库完全可能 throw "boom" 或者 throw { code: 42 }。编译器无法证明抛出来的一定是 Error,所以从 TypeScript 4.4 起,strict 模式下 catch 变量的类型就是 unknown(对应的开关叫 useUnknownInCatchVariablesstrict: true 会自动打开它)。2.4 讲过 unknown 的含义是「不知道,所以在收窄之前什么都不许做」。

于是 catch 里就有了一套固定的三步流程,本章前面那段代码已经完整演示过:

  1. 先试最具体的自定义错误if (e instanceof ToolCallError)——命中就能安全读 e.code
  2. 再试通用的 Errorelse if (e instanceof Error)——至少能读 e.message
  3. 最后兜底String(e)——什么都不假设,也绝不崩溃。

顺序不能反:ToolCallError 也是 Error,先写 instanceof Error 会把它一起吞掉,永远进不了第一个分支。

⚠️ 常见误解用 catch (e: any) 绕过编译错误
catch (e) 改成 catch (e: any) 确实能让编译器闭嘴,但这只是把「编译期报错」换成了「运行期崩溃」——真的有人 throw 了一个字符串时,e.message 得到 undefined,日志里留下一句「错误:undefined」,线索全无。多写两个 instanceof 分支,换来的是任何输入都不会二次崩溃。顺带一提,TypeScript 只允许把 catch 变量标注成 anyunknown,写 catch (e: Error) 是语法错误——因为这个承诺根本没人能保证。

异步的错误:await 处抛出,忘了 await 就没人接

2.5 讲过:await 一个失败的 Promise,失败会await 那一行以异常形式抛出。这意味着异步代码不需要另一套错误机制,普通的 try/catch 就能接住:

ts
try {
  const reply = await fetchModelReply(); // 失败在这一行抛出
  console.log(reply);
} catch (e) {
  console.log(`请求失败:${e instanceof Error ? e.message : String(e)}`);
}

危险的是漏掉 await。少了它,失败就发生在 try之外——try 块本身早已顺利执行完毕:

ts
async function fetchModelReply(): Promise<string> {
  throw new Error("模型服务返回 500");
}

try {
  fetchModelReply(); // 忘了 await
  console.log("try 块正常走完了——错误一点没被接住");
} catch {
  console.log("这一行永远不会执行");
}

真实输出(Node.js v26.5.0,进程退出码 1):

try 块正常走完了——错误一点没被接住

Error: 模型服务返回 500
    at fetchModelReply (…(省略:本机文件路径与堆栈其余各行))

这就是未处理的 Promise 失败(Unhandled Rejection)fetchModelReply() 立刻返回了一个 Promise,try 块随即正常结束、catch 从未被触发;直到事件循环稍后发现这个 Promise 失败了却无人认领,Node.js 才报告错误并以非零退出码结束进程。规矩很朴素:每条异步链的尽头,要么有人 awaittry/catch,要么挂着 .catch

图加载中…

图 2.9-1 一次失败沿调用栈向上冒泡的旅程
阅读顺序:从上到下。前三步是正常的调用方向,SyntaxError 产生之后走的是反方向。重点看中间两段:parseToolCall 把底层的 SyntaxError 翻译成带错误码的 ToolCallError,细节存进 cause 所以线索不丢;而 handleRequest 一行错误处理代码都没写,失败自动穿过它——这就是「第二条返回通道」省下的样板代码。最下面的 finally 说明收拾现场这一步不受成败影响。本章实验的 src/parse-tool-call.ts 就是图中间那一层的完整实现。

为什么要自动化测试

现在换到本章的后半段。上面写的错误处理,你怎么知道它真的对?

手工验证的办法是运行程序、看看输出对不对。它有三个躲不开的问题。第一,错误分支很难手工触发:一个函数有四种失败方式,你得手工构造四种坏输入,跑四遍,看四次输出——而且下次改代码时要再来一遍。第二,改一处要验证全部:今天为了修一个 bug 改了 parseToolCall,谁能保证另外三条分支没被弄坏?靠记忆和眼力,迟早会漏。这类「本来好好的功能被新改动弄坏」的问题有个专门的名字:回归(Regression)。第三,知识留不下来:「这个函数遇到缺省的 arguments 会补成空对象」这种约定只存在于作者脑子里,三个月后连他自己也不确定了。

自动化测试就是把这些验证写成代码,一条命令跑完全部,几十毫秒给出「全绿」或者「第 3 条红了」。它顺带解决了第三个问题:一份写得好的测试,本身就是这个函数最准确的说明书——因为它必须能真的跑通,不会像注释那样悄悄过期。这就是常说的测试即文档

📘 概念断言(Assertion)
测试代码里表达「我断定这里应该是什么样」的语句,例如 assert.equal(half(8), 4)。断言成立就静悄悄地过去,不成立就抛出一个 AssertionError,测试框架捕获它并把这条测试标红。所以断言本质上就是本章前半段的 throw——测试框架不过是一个专门收集异常并汇总报告的运行器。

node:test + node:assert:最小用法

Node.js 从 18 版起内置了测试运行器 node:test(20 版后转为稳定),断言库 node:assert 更是一直都在。用它们写测试不需要安装任何第三方包。先有一个被测函数 half.ts

ts
export function half(n: number): number {
  if (n % 2 !== 0) {
    throw new Error(`${n} 不是偶数`);
  }
  return n / 2;
}

再写测试文件 half.test.ts(文件名带 .test 是通行约定,方便工具批量找到它们):

ts
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { half } from "./half";

describe("half", () => {
  it("偶数返回它的一半", () => {
    assert.equal(half(8), 4);
  });

  it("奇数抛出 Error", () => {
    assert.throws(() => half(7), /不是偶数/);
  });
});

tsx --test half.test.ts 运行,真实输出(毫秒数每次不同):

▶ half
  ✔ 偶数返回它的一半 (0.265792ms)
  ✔ 奇数抛出 Error (0.156709ms)
✔ half (0.82875ms)
ℹ tests 2
ℹ suites 1
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 95.261

要认识的东西不多:

  • describe("名字", () => { ... }) 把一组相关测试归到一个标题下,只是为了输出好读,可以嵌套。
  • it("这个函数应该……", () => { ... }) 是一条测试用例。名字要写成一句人话的行为描述——上面两行 it 连起来读就是 half 的完整说明书,这正是「测试即文档」落地的地方。
  • assert.equal(实际值, 期望值) 比较两个值(node:assert/strict 这个入口用的是严格比较,不做类型转换)。比较对象内容用 assert.deepEqual,它逐层比较字段,而不是判断「是不是同一个对象」。
  • assert.throws(函数, 校验) 断言这个函数必须抛出异常。第二个参数用来校验抛出的到底是什么:可以是上面这样的正则(匹配 message),也可以是一个返回布尔值的函数——本章实验里用后者精确锁定「是 ToolCallErrorcode 等于 invalid_json」。只写 assert.throws(fn) 而不校验是很弱的断言:抛出任何异常都算通过,哪怕抛的是一个完全不相干的 bug。

把第一条断言故意改成 assert.equal(half(8), 5),真实的失败报告是这样(已省略堆栈):

  ✖ 偶数返回它的一半 (1.282917ms)
ℹ pass 1
ℹ fail 1

✖ failing tests:
✖ 偶数返回它的一半 (1.282917ms)
  AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:

  4 !== 5

报告直接给出「哪条测试、哪个断言、实际值与期望值分别是什么」。这就是自动化测试的日常价值:把「程序好像哪里不对」变成「第 7 行的这个断言,得到 4 却期望 5」。

图加载中…

图 2.9-2 有测试之后的日常改代码循环
阅读顺序:从左到右,注意它是一个环。关键在于「运行一条命令」这一步的成本极低——几十毫秒、无需手工构造输入,所以你才愿意每改一次就跑一次,把回归挡在提交之前。没有自动化测试时,这个环的成本高到人们只会在「感觉有必要」时才走一遍,于是 bug 就漏了出去。本章实验第 4 步会让你亲手走一遍红→改→绿。

Pi 中哪里用到了它

本章教的三样东西——带错误码的自定义错误、把 unknown 收窄并翻译成自己的错误类型、测试文件——在 Pi 里全是主力写法,而且形态和上面的示例几乎一模一样。

先看错误定义。Pi 在 packages/agent/src/harness/types.ts 里集中定义了一整族错误 class:FileErrorExecutionErrorCompactionErrorBranchSummaryErrorSessionErrorAgentHarnessError——每一个都配一个字面量 union 的错误码类型。文件操作用的这个是典型代表:

ts
/** Error returned by {@link FileSystem} file operations. */
export class FileError extends Error {
	/** Backend-independent error code. */
	public code: FileErrorCode;
	/** Absolute addressed path associated with the failure, when available. */
	public path?: string;

	constructor(code: FileErrorCode, message: string, path?: string, cause?: Error) {
		super(message, cause === undefined ? undefined : { cause });
		this.name = "FileError";
		this.code = code;
		this.path = path;
	}
}
earendil-works/pi@c13ffe1第 160–173 行在 GitHub 查看 ↗
Pi 的文件错误:extends Error、错误码 code、可选的 cause,构造函数三行——与本章示例的 ToolCallError 逐行对应。紧邻上方第 150–158 行的 FileErrorCode 是一个八个成员的字面量 union(aborted / not_found / permission_denied / …)。源码注释里的 "Backend-independent"(与后端无关)点出了错误码存在的理由:无论底层换成哪种文件系统实现,调用方看到的错误码都是同一套。

再看 unknown 的收窄。Node.js 的文件 API 抛出的错误五花八门,Pi 用一个专门的函数把它们统一翻译成 FileError

ts
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) {
			case "ABORT_ERR":
				return new FileError("aborted", message, path, cause);
			case "ENOENT":
				return new FileError("not_found", message, path, cause);
			// …(省略:EACCES / EPERM / ENOTDIR / EISDIR / EINVAL 五个分支,形态完全相同)
		}
	}
	return new FileError("unknown", cause.message, path, cause);
}
earendil-works/pi@c13ffe1第 92–118 行在 GitHub 查看 ↗
把任意 unknown 翻译成带稳定错误码的 FileError。三段结构与本章讲的收窄流程完全一致:先试最具体的(已经是 FileError 就原样返回),再用 type guard isNodeError 收窄到「带 code 的 Node.js 错误」并逐个映射,最后兜底成 unknown。每个分支都把原始错误放进 cause,底层细节一点没丢。isNodeError 正是 2.4 讲的自定义 type guard(返回类型写作「参数名 is 类型」),而它调用的 toError 就是 2.4 引过的那个把 unknown 规整成 Error 的函数。

调用它的地方就是最普通的异步 try/catchreadTextFile(同文件第 499–508 行)先检查 AbortSignal(2.7 的内容),然后 try { return ok(await readFile(...)) } catch (error) { return err(toFileError(error, resolved)) }——await 处抛出的失败被接住,翻译成 FileError,再装进 2.4 见过的 Result 里返回。注意这个组合:Pi 在内部用 throw 传播失败,但在公开的接口边界上换成 Result 返回值,逼迫调用方必须检查。两条通道各用在合适的地方,这个取舍在 5.6 取消与错误处理 会展开。

最后是测试。Pi 用的测试框架不是 node:test 而是 vitest(一个流行的第三方测试框架),但常用 API 几乎同源——describe / it 的用法完全一样,只是断言从 assert.equal(a, b) 换成了链式的 expect(a).toBe(b)

ts
import { mkdtempSync, readdirSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { expandPath, resolveReadPath, resolveToCwd } from "../src/core/tools/path-utils.ts";

describe("path-utils", () => {
	describe("expandPath", () => {
		it("should expand ~ to home directory", () => {
			const result = expandPath("~");
			expect(result).not.toContain("~");
		});
earendil-works/pi@c13ffe1第 1–12 行在 GitHub 查看 ↗
Pi 的一个真实测试文件开头。结构与本章示例逐项对应:先 import 被测函数,再 describe 分组(这里嵌套了两层),每个 it 用一句英文行为描述当作用例名。beforeEach / afterEach 是本章没讲的钩子,用来在每条用例前后建立和清理临时目录。这类测试文件在 Pi 仓库里有数百个,仅 packages/coding-agent/test/ 一个目录下就有上百个。

从源码结构看,Pi 的测试主要覆盖两类东西:纯函数的边界情况(像上面的路径处理),以及带编号的历史 bug——packages/coding-agent/test/suite/regressions/ 目录下的文件名都以 issue 编号开头,例如 5724-sigterm-signal-exit.test.ts。后者是「测试即文档」最直白的形式:每个文件都在说「这个 bug 修过,别再犯」。

实践任务

🛠 实践任务带错误分支的解析函数 + 6 个自动化测试labs/typescript-basics/12-errors-tests

目标:把本章两半内容各走一遍——用自定义错误 class 处理四种失败,再用 node:test 给它们写测试并跑绿。实验目录:labs/typescript-basics/12-errors-tests(全书实验索引见实践任务索引)。

步骤

  1. 进入实验目录,安装依赖并运行两条命令:

    sh
    cd labs/typescript-basics/12-errors-tests
    npm install
    npm start   # 错误处理的四个演示
    npm test    # 6 个自动化测试
  2. 对照 expected-output.txt 核对 npm start 的四个部分,重点看第 3 部分——两次「关闭日志」分别来自一次成功和一次失败;

  3. src/parse-tool-call.ts,找出四个错误码分别对应哪一行 throw,以及 cause 是在哪一行装进去的;

  4. 改一改:把 src/parse-tool-call.tsmissing_name 那个 if 整段删掉,npm test 观察哪一条测试变红、失败报告怎么写,再改回来;

  5. 加练:把 src/main.ts 第 4 部分 await fakeReadFile("ghost.txt")await 删掉,运行 npm start,观察「未处理的 Promise 失败」如何绕过 try/catch 让进程非零退出。

预期现象npm startexpected-output.txt 逐字一致;npm test 显示 pass 6fail 0;第 4 步应看到「缺少 name 字段时抛出 missing_name」那条变成 ,统计行变成 fail 1

如何判断成功:四步改动的结果都与你的预测一致,并能口头回答——为什么分辨失败要靠 code 字段而不是比对 message 文本?

常见错误tsx: command not found 说明还没在实验目录里 npm install;在 catch (e) 里直接写 e.code 会编译报错 'e' is of type 'unknown',必须先用 instanceof 收窄。

对应源码位置packages/agent/src/harness/types.ts 第 160–173 行(FileError,实验里的 ToolCallError 是它的简化版)、packages/agent/src/harness/env/nodejs.ts 第 92–118 行(toFileError 的收窄流程)。

本章小结

  • 失败需要第二条返回通道:哨兵值会被无声忽略,Result 要求每层转发,而 throw 让失败沿调用栈自动冒泡,中间不关心的层一行代码都不用写;无人接住则程序终止,失败绝不会消失。
  • try 圈出可能失败的代码(抛出后块内剩余代码被跳过);catch 决定怎么办;finally 无论成败都执行,专门用来收拾现场。空 catch {} 会让失败彻底消失,要慎用。
  • Error 的四个字段:message(给人看)、name(种类名)、stack(调用栈,只有 Error 对象才有)、cause(底层原因,翻译错误时用它保住线索)。
  • 自定义错误 = extends Error + 一个字面量 union 的 code 字段。message 给人看、随时可改;code 给代码看、承诺稳定——分辨失败种类必须靠 code
  • strict 模式下 catch (e)eunknown(TypeScript 4.4+ 的 useUnknownInCatchVariables),因为任何值都能被 throw。收窄顺序固定:最具体的自定义错误 → Error → 兜底 String(e),顺序反了会被父类吞掉。
  • 异步失败在 await 那一行以异常形式抛出,普通 try/catch 就能接住;忘写 await,失败就发生在 try 块之外,变成未处理的 Promise 失败,Node.js 报错并以非零退出码结束进程。
  • 自动化测试解决三件事:难手工触发的错误分支、改一处要验证全部(防回归)、口头约定会遗忘(测试即文档)。node:test + node:assert 是 Node.js 内置的零依赖最小方案:describe 分组、it 一条用例、assert.equal / assert.deepEqual 比较值、assert.throws 断言抛出——assert.throws 一定要带第二个参数校验抛的是什么。
  • Pi 把这套写法用到了底:types.ts 里一族带错误码的自定义错误、toFileErrorunknown 逐层收窄成 FileError、数百个 vitest 测试文件(含以 issue 编号命名的回归测试)。

关键术语:异常(Exception)、throwtry / catch / finally、调用栈(Call Stack)、冒泡、哨兵值、Errorname / message / stack / cause、自定义错误、错误码(error code)、useUnknownInCatchVariables、未处理的 Promise 失败(Unhandled Rejection)、自动化测试、断言(Assertion)、测试用例、回归(Regression)、测试即文档、node:test / node:assert、vitest、describe / it / assert.equal / assert.deepEqual / assert.throws

关键源码索引packages/agent/src/harness/types.tsFileErrorCodeFileError(第 150–173 行),同文件还有 ExecutionErrorCompactionErrorBranchSummaryErrorSessionErrorAgentHarnessError(第 175–266 行);packages/agent/src/harness/env/nodejs.tsisNodeErrortoFileError(第 92–118 行)、readTextFile 的异步 try/catch(第 499–508 行);packages/coding-agent/test/path-utils.test.ts(vitest 测试文件样例,第 1–12 行)

自测问题

  1. 有人在 catch 里写 if (e.message.includes("not found")) 来判断「文件不存在」。这样写有哪两个问题?换成本章的写法要怎么改?
  2. catch (e)e 的类型为什么是 unknown 而不是 Error?如果把收窄顺序写反——先 if (e instanceof Error)else if (e instanceof MyError)——会发生什么?
  3. 一个 async 函数里写了 try { doAsyncWork(); } catch { ... }(漏了 await),而 doAsyncWork 会失败。程序会打印什么、退出码是几?为什么 catch 接不住?
  4. assert.throws(() => parse("坏输入")) 这条断言在什么情况下会「通过但毫无意义」?加上第二个参数能挡住哪类问题?

下一章预告:第二部分到此结束——TypeScript 的类型、异步、模块、错误与测试全部到齐,读 Pi 源码的语言门槛已经清空。接下来进入第三部分「Agent 基础概念」,从 3.1 LLM 与模型 API 开始:大语言模型(LLM,Large Language Model)到底是什么、一次 API 调用发出去和收回来的分别是什么、为什么它天生不记得上一句话——这些是理解 Agent Harness 存在意义的起点。

本书分析的 Pi 版本:earendil-works/pi@c13ffe1(2026-07-30)