TypeScript 精读 09:异步的类型建模

第 09 章 异步的类型建模

代码基线:commit a89bca1316(2026-10-06)| DSH 0.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 放进签名,编译器就能帮你做两件事:

  1. 强制调用方提供取消通道(不能忘传)
  2. 拦住那些既不收 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。