第 09 章 异步的类型建模
代码基线:commit
a89bca1316(2026-10-06)| DSH0.2.0-rc.1| 本系列讲次:T8
本章导读
这一章讲并发在类型里长什么样。
前面八章讲的全是同步的东西:类型怎么查询、怎么判断、怎么提取、怎么合并。
异步一旦进来,整套模型会变——因为类型系统对"什么时候"一无所知。
DSH 的异步体量能说明问题:101,160 处 async / await。
这个数字本身就值得警惕——它意味着异步是日常,不是特例。
而 DSH 在这里系统性地不教:它假定你已懂 Promise,于是:
AsyncIterable<StreamChunk>命中 64 个文件,没有一处解释它是什么Awaited<ReturnType<...>>出现 186 次,没有一处说明为什么必须这么写- [
agent.ts``packages/core/agent-loop/src/agent.ts里有 18 处取消检查,
没有一处说明为什么不能省
本章要讲的就是这三样。读完你能回答:"为什么 DSH 写
Awaited<ReturnType<typeof remote.session.list>> 而不是
ReturnType<typeof remote.session.list>?"
答案藏在一个叫 Awaited 的内建工具类型里,而它存在的原因只有一句话。
一、Promise<T> 只说了一半
declare function f(): Promise<number>
类型系统知道:f 返回一个"最终会变成 number 的东西"。但它不知道:
- 什么时候变?同步还是微任务之后?
- 会失败吗?失败时是什么?
- 能取消吗?
- 会被谁等?
这些全都不是类型的事。 Promise<T> 只是一个"未来的容器"的类型。
本章反复要面对的就是这个边界:类型能描述容器的形状,描述不了容器的时序。
凡是时序相关的东西——取消、超时、并发度——都必须靠运行时机制,
类型最多只能在编译期拦住误用。
本章第一条纪律
遇到"异步"先分类:这件事是类型问题(返回值形状)还是时序问题
(取消、超时、并发)?前者能用类型解决,后者只能靠运行时机制,
类型最多帮你把误用挡在编译期。
二、AsyncIterable:流式返回的标准形状
DSH 最常见的异步返回值不是 Promise,而是 AsyncIterable:
| 写法 | 次数 |
|---|---|
AsyncIterable<StreamChunk> |
64 个文件命中 |
AsyncIterable<unknown> |
93 处 |
AsyncIterable<Uint8Array> |
37 |
AsyncIterable<string> |
36 |
64 个文件用 AsyncIterable<StreamChunk>。 模型流式输出的标准形状。
2.1 它和 Iterable 是两种东西
declare const ai: AsyncIterable<number>
for await (const x of ai) { const t: number = x } // ✓ 通过
const it: Iterable<number> = ai // ✗ 报错
error TS2741: Property '[Symbol.iterator]' is missing in type
'AsyncIterable<number>' but required in type 'Iterable<number>'.
这是一个会咬人的差异。 AsyncIterable 只有一个
[Symbol.asyncIterator],没有 [Symbol.iterator]。
于是:一个返回 AsyncIterable 的函数,不能被 for...of 消费,
只能被 for await...of 消费。 把它们搞混会在编译期报 TS2741,
这一点是好的——至少你不会在运行时才发现。
2.2 为什么 DSH 选它而不是 Promise<T[]>
Promise<StreamChunk[]> 要求先收完全部内容才能开始消费。
而流式输出的本质是边产出边消费:第一个 chunk 产生时就可以喂给下游。
AsyncIterable 表达的就是"这还没完,但会一个个来"。
这是 Promise 表达不了的——Promise 是一次性的。
本章第二条纪律
选择
Promise<T>还是AsyncIterable<T>,取决于消费方能否在 T 产完之前开始工作。
能 →AsyncIterable;不能 →Promise。
这不是风格问题,是接口形状决定了谁先动。
三、Awaited:本章的核心
3.1 Awaited 存在的唯一理由
第 02 章说过类型系统不会自动展平嵌套的泛型。看这个:
declare function f(): Promise<Promise<number>>
await f() 的结果是 Promise<number> 还是 number?
运行时是 number——JS 的 await 会递归展平。
但类型层面,如果有个函数声明返回 Promise<Promise<number>>,
直接用它当 number 会报错,除非你写 Awaited<>:
type W1 = Awaited<Promise<Promise<number>>> // number(实测通过)
const a1: W1 = 42
Awaited<T> 就是把 await 的展平规则写进类型。
3.2 DSH 为什么需要它
真正的需求出现在**"可能同步、也可能异步"**的 API 上:
declare function maybe<T>(v: T | Promise<T>): T | Promise<T>
type Synced<T> = Awaited<ReturnType<typeof maybe<number>>>
对调用方来说,maybe(...) 的结果是"用 await 接就行"。
而要在类型层面表达"await 之后是什么",就需要:
Awaited<ReturnType<typeof maybe<number>>> // number
DSH 的服务层大量是这个形状,而它的惯用写法是:
Awaited<ReturnType<typeof remote.session.list>>
**这个组合在本仓库出现 186 次(实测)。它是 DSH 里最常见的
"把一个可能同步的远程调用归一化成它 await 之后的类型"的写法。
本章第三条纪律
看到
Awaited<ReturnType<F>>,它几乎总是表达同一件事:
"给我F在 await 之后的类型"。
当被调用的东西是T | Promise<T>形状的远程/插件接口时,这一句不能省——
少了它,类型就是错的(是T | Promise<T>而不是T)。
四、Promise.all 的元组陷阱
Promise.all 看起来简单,实际是本章最容易写错的地方。
const mixed = [Promise.resolve(1), Promise.resolve('x')]
const r1: [number, string] = await Promise.all(mixed)
error TS2322: Type '(string | number)[]' is not assignable to type '[number, string]'.
Target requires 2 element(s) but source may have fewer.
数组字面量的自然类型是 (Promise<number> | Promise<string>)[],
于是 Promise.all 拿到的是可变数组,返回类型自然是
(string | number)[] ——元素类型合并,顺序信息全丢。
加上第 06 章的 as const 就好了:
const tup = [Promise.resolve(1), Promise.resolve('x')] as const
const r2: [number, string] = await Promise.all(tup) // ✓ 通过
const r3: (number | string)[] = await Promise.all(tup) // ✓ 也可赋给更宽的
这是一个 as const 的新用途:第 06 章说它用来保留字面量,
这里它还用来把数组变成元组,从而保住 Promise.all 的位置对应关系。
本章第四条纪律
需要
Promise.all的位置信息([A, B]而不是(A|B)[])时,
必须给数组加as const。否则你拿到的是一个联合数组,
而变量a和b的对应关系在类型上已经不存在了。
五、allSettled:错误是值,不是异常
Promise.all 有一个行为是类型上完全看不见的:一个 reject 会让整体 reject。
这意味着你丢掉了其余已完成的结果。
Promise.allSettled 不抛,它把每个结果包成"成功/失败"的判别式:
const settled = await Promise.allSettled([Promise.resolve(1)])
const s1: PromiseSettledResult<number> = settled[0]! // ✓
const s2: number = settled[0]!.value // ✗ 报错
error TS2339: Property 'value' does not exist on type 'PromiseSettledResult<number>'.
Property 'value' does not exist on type 'PromiseRejectedResult'.
报错信息本身就是这一章最好的教材:value 属性只存在于
PromiseFulfilledResult,而在 PromiseSettledResult 上访问它,
你必须先收窄:
const r = settled[0]!
if (r.status === 'fulfilled') { const n: number = r.value } // ✓ 这里才安全
注意 value 不在 PromiseRejectedResult 上——设计如此,
这样收窄才是可靠的(第 06 章讨论过:类型差异必须可辨才安全)。
本章第五条纪律
用
Promise.all时想清楚:一个失败要放弃全部吗?
不是的话用allSettled,并显式收窄status。
Promise.allSettledResult<T>上的value/reason
在窄化之前取不到,这不是限制,是保护。
六、取消:类型管不了,但能挡误用
[packages/core/agent-loop/src/agent.ts``packages/core/agent-loop/src/agent.ts
里有 18 处取消检查。它们长这样:
if (signal.aborted) return
// ... 实际工作 ...
signal.throwIfAborted()
为什么需要 18 处? 因为取消可能发生在任何一步之间。
一个长循环里,编译器不知道 signal 什么时候会变,所以每一处可能长时间
阻塞的边界前都要显式检查。
这是本章第一节那条纪律的典型应用:
- "这个循环能不能被取消"——时序问题,类型管不了
- "这个函数收不收
signal"——类型问题,可以在编译期挡
所以真实签名长这样:
async function run(input: Input, signal: AbortSignal): Promise<Output>
把 signal 放进签名,编译器就能帮你做两件事:
- 强制调用方提供取消通道(不能忘传)
- 拦住那些既不收
signal、又声称能取消的接口
AsyncIterable 在这里也有讲究:一个支持取消的流应该在遍历时检查 signal,
或者在迭代器内部绑定。类型上体现为 AsyncIterable<T>(无取消参数)
vs 一个带 { signal } 的选项对象——两种接口契约强度不同。
本章第六条纪律
取消/超时这类时序能力不要藏在实现里——
放进签名,让它在类型上可见(signal: AbortSignal参数)。
签名里看不见的能力,等于调用方无法依赖的能力。
七、本章小结
- 类型能描述容器的形状,描述不了容器的时序。
先分清每件事是类型问题还是时序问题。 AsyncIterable<T>表达"还没完,但会一个个来",
与Iterable不兼容(TS2741)。选它还是选Promise<T>,
取决于消费方能否在完成前开始工作。Awaited<T>是把await的展平规则写进类型。
Awaited<ReturnType<F>>在 DSH 出现 186 次,它表达的是
"给我 F 在 await 之后的类型"——远程接口返回T | Promise<T>时不能省。Promise.all要位置信息就必须加as const,否则退化成联合数组。allSettled的value在收窄status之前取不到,
这是保护而不是限制。- 取消属于签名,不属于实现:
signal: AbortSignal让能力在类型上可见。
最后一章是收口:把前九章的东西合成一套"在一个陌生的大仓里干活"的方法。
本章实验(附录)
实验脚本:[labs/M9-async-lab.tslabs/M9-async-lab.ts
运行(在仓库根目录):
export PATH="/opt/homebrew/bin:$PATH"
cd /Users/ygs/ygs/deepseek-harness
node --import tsx/esm "ts-源码精读/labs/M9-async-lab.ts"
这个实验做什么:前六节的每一条都做成编译成败判定,
并直接读 agent-loop/src/agent.ts 数出取消检查的真实分布。
预期输出(本机实测,任何一行对不上就说明基线变了):
── 组 1:AsyncIterable
✓ for await 能消费 AsyncIterable
✓ AsyncIterable 不可赋给 Iterable(TS2741)
✓ AsyncIterable 与 Iterable 各自带正确的迭代协议
── 组 2:Awaited
✓ Awaited 穿透嵌套 Promise
✓ Awaited 对非 Promise 是恒等(string → string,不是收窄成字面量)
✓ Awaited 归一化 T | Promise<T> 这类远程接口形状
── 组 3:Promise.all 的元组陷阱
✓ 无 as const 时退化为联合数组
✓ 加 as const 得到位置正确的元组
✓ 元组可赋给更宽的数组类型
── 组 4:allSettled 的保护
✓ 未收窄时取不到 value(TS2339)
✓ 收窄 status 后得到精确类型
── 组 5:DSH 的真实分布
✓ agent.ts 里的取消检查确为 18 处量级(实测 18)
✓ Awaited<ReturnType<...>> 确为本仓库高频惯用法(实测 186 处)
✓ AsyncIterable<StreamChunk> 确为最高频的异步形状(实测 64 个文件命中)
全部断言通过。
本章的勘误条目见 [附录 A · 勘误总表附录A-勘误总表.md。