TypeScript 生存集:四个 DemoEvent 怎样进入测试
你将得到什么
序章播放的是一条固定轨迹。现在把其中的事件缩小成四个普通对象:请求开始、文字到达、 请求完成、请求取消。它们共享type 字段,却各自保存不同的数据。
type 怎样帮助编译器区分对象,
switch 怎样逐分支收窄类型,外部的 unknown 怎样经过运行时检查,测试又怎样通过
ESM 导入编译后的文件。Promise 只保留后续异步流需要的两个动作:函数交出一个未来
结果,调用者用 await 等到它完成。
最终只新增一个文件:
packages/pi-course/src/survival/events.ts。前端、DOM、装饰器和复杂泛型都不在这条
轨迹上。
type 区分四种事件
先给四个对象一个共同名字:
DemoEvent 的值可以是 started、delta、finished 或 aborted,
但不会同时是其中两种。四个成员都带有字面量字段 type,所以它是一组带标签的联合
类型(tagged union)。
这个写法把合法字段组合写进类型。delta 一定有 text,finished 一定有 reason;
started 和 aborted 没有这两个字段。若把它们合并为
{ type: string; text?: string; reason?: string },started 携带 reason 这样的无效
对象也会通过类型检查。
event.type 还会改变编译器对整个对象的认识:
event.type === "delta" 的分支后,编译器已经排除另外三个成员,所以
event.text 可以直接读取。这个过程叫收窄(narrowing)。分支结束后,参数仍是完整的
DemoEvent。
Checkpoint 01 · 建立 TypeScript 协议证据模式: 重建起终点:
parent 是第 00 章完成后的起点;target 是 2 项聚焦测试通过的终点。教学文件: packages/pi-course/src/survival/events.ts动手前只需知道: 四种 DemoEvent 共享 type 标签;formatEvent() 根据标签
读取当前成员的字段;readDelta() 从 unknown 中检查并构造一条 delta。第一次红灯: parent 还没有 events.ts。build 会报告
Cannot find module '../src/survival/events.js'。测试中的 .js 指向编译后的 ESM 文件,
不需要改成 .ts。第一步: 先不看 target diff。创建教学文件,写出 DemoEvent 与
formatEvent(),运行第一项聚焦测试。随后补上 readDelta(),让两项测试一起通过。聚焦测试: packages/pi-course/test/01-typescript-survival.test.ts定位命令: npm run checkpoint -w @pi/course -- 01练习目录: npm run practice -w @pi/course -- 01聚焦运行: npm run build -w @pi/course,然后运行
node --test packages/pi-course/dist/test/01-*.test.js通过证据: 2 项测试观察四种事件的格式与顺序、一条合法 delta,以及数字 text
被运行时边界拒绝。switch 把事件写成文字
formatEvent() 收到一条 DemoEvent。每个 case 都用刚才的 type 标签选中一个
联合成员:
text,finished 分支才能读取
reason。四个成员都处理完以后,default 中已经没有可能的值,所以 event 可以赋给
never。never 在这里表示“这条路径没有合法输入”。
以后给联合类型增加新成员时,漏改 switch 会改变这个结论。剩余的 event 不再是
never,编译器会把遗漏直接标在 const unreachable: never = event 这一行。
实践 1.1 · 格式化四种 DemoEvent目标: 让 预期:
type 标签选择正确字段,并让格式化结果保持事件顺序。文件: packages/pi-course/src/survival/events.ts动作:- 写出 started、delta、finished、aborted 四个联合成员。
- 实现
formatEvent()的四个case。 - 在
default中加入never穷尽检查。 - build 后只运行名称含“tagged union”的测试。
1/1。实际数组依次是 start r1、delta r1 Pi、
finish r1 stop、abort r2。unknown 在检查后变成 delta
上面的四个对象由课程代码创建,TypeScript 能检查它们。网络响应、配置文件和
JSON.parse() 的结果来自运行时。边界代码把这类值明确保存为 unknown:
unknown 暂时不允许读取字段,因为程序还没有证据说明它是对象。readDelta() 逐项
建立这份证据:
typeof、null 和数组检查先确认 value 是普通对象;type
字段再把它收窄到 delta 分支;最后,requestId 与 text 各自经过存在性和字符串检查。
到这一步,函数才读取两个数据字段并构造新的 delta。外部值上的其他字段不会顺便进入
Agent 协议。
用一条合法输入和一条非法输入观察边界:
as DemoEvent 只能改变编译器看待一个值的方式,不会把数字 42 转成字符串,也不会
检查 JSON 中的任何字段。联合类型约束进程内已经可信的对象;readDelta() 负责外部值
进入这组类型之前的运行时检查。
实践 1.2 · 检查一条外部 delta目标: 让合法对象进入课程协议,让错误字段停在边界。文件: 预期:
packages/pi-course/src/survival/events.ts动作:- 让参数保持
unknown,检查非 null 对象并排除数组。 - 检查
type、requestId和text。 - 从已经收窄的字段构造新的 delta 对象。
- 非法输入抛出
invalid delta event,随后运行两项聚焦测试。
2/2。合法对象格式化为 delta r1 Pi;text: 42 在
readDelta() 中被拒绝,formatEvent() 不会收到它。ESM 测试加载编译后的文件
聚焦测试写在 TypeScript 文件中,却用.js 后缀导入教学模块:
NodeNext 与 ESM;npm run build
调用 tsc,把源码和测试一起写入 dist:
DemoEvent 只供编译器检查,所以 type DemoEvent 不会成为运行时导入。formatEvent 和
readDelta 会留在 JavaScript 中,由 Node 的测试运行器调用。这里把测试路径改成
.ts,反而会让编译后的 JavaScript 指向一个运行时不存在的文件。
一项测试固定四条输出,另一项测试观察 unknown 边界:
tsc 检查联合成员、分支字段和模块连接;node --test
把具体输入交给编译后的函数,再比较返回值和异常。两者都通过,才说明当前静态形状和
已覆盖行为同时成立。
Promise 只增加“稍后得到结果”
下一章的事件会逐个到达,最终消息也要等到流结束才出现。这里用同一条 delta 看 Promise 的最小执行过程:formatLater() 时,调用者立刻拿到 Promise<string>。await pending 暂停当前
async 函数或 ESM 模块的后续语句;第二行同步日志仍然先出现。当前同步代码退出后,
Promise 回调与 await continuation 才依次取得字符串。这条顺序直接显示:等待暂停的是
当前 async 控制流,不是整个 Node 进程;formatEvent() 产生的字符串也没有改变。
第 02 章会让 next() 返回等待下一条事件的 Promise。第 05 章会用
for await...of 顺序读取网络片段。多个工具何时可以并发、结果按什么顺序写回,会在
Agent Loop 已经出现后处理;这里不提前引入调度规则。
两项测试覆盖到哪里聚焦测试覆盖四种事件的格式和顺序、一条合法 delta,以及数字
text 这一项边界反例。
never 对新联合成员的诊断来自 TypeScript 编译器;上面的 Promise 顺序是运行时观察,
没有进入这两项聚焦测试。第 02 章会把“稍后完成”放进真正逐条到达的事件流,再给等待
过程增加可执行证据。与当前上游 Pi 对照固定提交
8479bd8 的 packages/agent/src/types.ts 与
packages/ai/src/types.ts 使用带标签的联合类型表达消息和事件,并通过 import type
连接只存在于编译期的类型。课程用四个 DemoEvent 练习同一组语言机制,没有复制
上游的 Provider 字段、复杂泛型和完整运行时验证。完成正常路径后的诊断实验
类型检查和行为测试可以分别撞出一次缺口。 第一种改动是在DemoEvent 末尾临时加入:
formatEvent() 不变,再运行 build。现有测试没有构造 paused,但 default 中的
event 已经可能是 paused,never 赋值会产生静态错误。
第二种改动保留所有类型,只把 delta 分支改成:
delta Pi 与 delta r1 Pi 的差异。
本节验收
Checkpoint 01 · 四个 DemoEvent 通过两条证据链运行:结果应为
2/2。沿着同一组对象回答五个问题:type: "delta"怎样让编译器允许读取text?- 新增联合成员却遗漏
case时,never行为什么会报错? text: 42在哪一步被拒绝?- 测试为什么导入
events.js,实际施工文件却是events.ts? - 调用 async 函数后,Promise 与
await分别代表什么?
packages/pi-course/src/survival/events.ts。测试与 lockfile 保持原样。
npm run checkpoint -w @pi/course -- 01 可以重新查看 parent 与 target;
npm run practice -w @pi/course -- 01 <新目录> 会从同一 parent 创建新的隔离练习目录。
下一章会把 DemoEvent 的静态数组换成随时间到达的事件流。可选迁移练习
小结
四个DemoEvent 都是普通对象。共享的 type 标签把它们组成联合类型,也让编译器在
分支中收窄到正确成员。never 检查联合成员是否全部处理;readDelta() 则用运行时
检查让外部 unknown 获得信任。
tsc 把 TypeScript 源码和测试编译成 ESM JavaScript,node --test 再观察具体行为。
async 函数交出 Promise,await 取得稍后完成的结果。下一章会把这些机制放进同一个
EventStream,让事件的到达时间也进入协议。