深入源码理解 TypeScript 内置工具类型 Awaited<Type> 的原理
读 TypeScript 内置工具类型源码有个挺有意思的地方:它们通常不长,但很耐看,短短几行代码里经常藏着 TypeScript 类型系统里最核心的那些能力。
Awaited<Type> 就是一个很好的例子。它的源码只有几行,却同时用到了条件类型、infer、递归类型,以及和 await 行为密切相关的 Thenable 判断。
我第一次看文档时,直觉上以为它只是把 Promise<T> 里的 T 拿出来。但真正看源码会发现,Awaited 明显比这个想法讲究得多。它要模拟的不是"把 Promise 拆开"这么简单,而是尽量贴近 await 在 JavaScript 运行时里的行为。
本文就沿着源码一步步拆开它。
先看源码
TypeScript 内置声明文件里的 Awaited 定义:
ts
/**
* Recursively unwraps the "awaited type" of a type. Non-promise "thenables" should resolve to `never`. This emulates the behavior of `await`.
*/
type Awaited<T> = T extends null | undefined ? T : // special case for `null | undefined` when not in `--strictNullChecks` mode
T extends object & { then(onfulfilled: infer F, ...args: infer _): any; } ? // `await` only unwraps object types with a callable `then`. Non-object types are not unwrapped
F extends ((value: infer V, ...args: infer _) => any) ? // if the argument to `then` is callable, extracts the first argument
Awaited<V> : // recursively unwrap the value
never : // the argument to `then` was not callable
T; // non-object or non-thenable
源码位置在 TypeScript 仓库的 src/lib/es5.d.ts。官方文档里也提到,Awaited 从 TypeScript 4.5 开始引入,用来描述 await、Promise.then 这类操作递归展开 Promise 的结果类型。
我们先不看官方注释,先把整体逻辑翻译成一句话:
如果 T 是 null 或 undefined,保持原样;否则如果 T 是一个带有 then 方法的对象,就拿到 then 成功回调的第一个参数类型,再继续递归解包;如果都不是,就返回 T 本身。
第一段:为什么先处理 null 和 undefined
源码第一段是:
ts
T extends null | undefined ? T : ...
这一句的意思很直接:
ts
type A = Awaited<null>;
// null
type B = Awaited<undefined>;
// undefined
为什么要把这两个类型单独拎出来?
从运行时看,await null 的结果就是 null,await undefined 的结果就是 undefined。它们不是 Promise,也没什么可解包的。
从类型系统看,这里还和 strictNullChecks 有关。TypeScript 源码注释里把它称为一个 special case:在非严格空值检查模式下,null 和 undefined 的关系更宽松,如果不提前处理,后面的对象判断可能出现不符合预期的推导。
这个细节很小,但挺能说明问题:内置工具类型和我们平时写业务类型不太一样,它必须照顾很多编译配置和历史兼容性。
第二段:为什么不是判断 Promise,而是判断 Thenable
接下来这一段,是整个 Awaited 最关键的地方:
ts
T extends object & { then(onfulfilled: infer F, ...args: infer _): any }
? ...
: T
如果只是想解开 Promise,我们很容易写成这样:
ts
type SimpleAwaited<T> = T extends Promise<infer V> ? V : T;
这个版本当然能处理 Promise<string>,但它和 JavaScript 里的 await 还差了一点。
因为 await 并不只认识原生 Promise。它还认识一种更宽泛的东西:Thenable。
简单说,只要一个对象长得像这样:
ts
type Thenable<T> = {
then(onfulfilled: (value: T) => unknown): unknown;
};
它就可以被当成"类似 Promise 的东西"来等待。注意,这里强调的是"长得像",不一定非得是 Promise 实例。
例如:
ts
type CustomThenable = {
then(onfulfilled: (value: Date) => void): void;
};
type Result = Awaited<CustomThenable>;
// Date
所以源码里没有写 T extends Promise<infer V>,而是写成了结构化的 then(...) 判断。
这也很符合 TypeScript 的风格:它是结构类型系统,关心一个类型"长什么样",而不是它是不是某个类的实例。只要类型上有符合要求的 then 方法,它就能进入 Awaited 的解包逻辑。
这里的 object & ... 也不是随手写的。它相当于先限制:T 至少得是对象类型,然后再看它有没有可调用的 then。
所以普通基础类型不会被误判:
ts
type A = Awaited<string>;
// string
type B = Awaited<number>;
// number
type C = Awaited<boolean>;
// boolean
这和运行时也对得上:await 1 的结果就是 1,不会因为它不是 Promise 就报错。
第一次 infer:拿到 then 的成功回调
这一段里出现了第一个 infer:
ts
then(onfulfilled: infer F, ...args: infer _): any
这里先推导的不是 Promise 最终的值,而是 then 方法第一个参数的类型。
比如有这样一个类型:
ts
type T = {
then(onfulfilled: (value: string) => void): void;
};
把它套进源码之后,F 会被推导成:
ts
(value: string) => void
拿到 then 的第一个参数类型后,进入下一层判断。
第二次 infer:拿到真正的完成值
第二个 infer 在这里:
ts
F extends (value: infer V, ...args: infer _) => any
这次才是真正提取 await 之后的值类型。
为什么只看成功回调的第一个参数?把它和 Promise 的 then 放在一起看就很自然:
ts
promise.then((value) => {
// value 就是 Promise fulfilled 之后的值
});
对于类型系统来说,await promise 的结果,本质上就对应 then 成功回调里 value 的类型。
所以:
ts
type P = Promise<{ token: string }>;
type Result = Awaited<P>;
// { token: string }
Promise<{ token: string }> 的 then 成功回调大概可以理解为:
ts
(value: { token: string }) => unknown
于是 V 就是:
ts
{ token: string }
读到这一步,Awaited 的主体逻辑就比较清楚了:它不是简单地从 Promise<T> 里拿 T,而是从 then 的成功回调里拿 value。
这正是它能支持 Thenable 的关键。
为什么要递归 Awaited
提取出 V 之后,源码没有直接返回 V,而是返回:
ts
Awaited<V>
这就是递归,也是 Awaited 能处理多层 Promise 的原因。
因为成功回调拿到的值,仍然可能是一个 Promise 或 Thenable。
ts
type Nested = Promise<Promise<Promise<string>>>;
type Result = Awaited<Nested>;
// string
可以把它展开成:
ts
Awaited<Promise<Promise<Promise<string>>>>
// Awaited<Promise<Promise<string>>>
// Awaited<Promise<string>>
// Awaited<string>
// string
这正是 await 的行为:它会一直等到最终的非 Promise-like 值。
为什么有一个 never 分支
源码里还有一个很容易被忽略的分支:
ts
F extends (value: infer V, ...args: infer _) => any
? Awaited<V>
: never
如果 then 的第一个参数不是函数,就会得到 never。
例如:
ts
type BadThenable = {
then(onfulfilled: string): void;
};
type Result = Awaited<BadThenable>;
// never
这个类型就很别扭:它有一个可调用的 then,看起来像 Thenable,但 then 接收的成功回调又不是函数。
TypeScript 在这里选择了 never。我的理解是:这个类型声称自己像 Promise-like,但它又不满足正常解包的约定,所以类型系统没法给出一个合理的完成值类型。
顺手再区分一下另一个情况:
ts
type NotThenable = {
then: string;
};
type Result = Awaited<NotThenable>;
// { then: string }
这里的 then 不是方法,不符合 { then(onfulfilled: ...): any } 的结构,所以它不会进入 Thenable 分支,最终会返回原类型。
这两个例子的差别很小,但正好能说明源码判断的层次:
- 先看是不是对象,并且有没有可调用的
then; - 再看
then的第一个参数是不是可调用的成功回调; - 如果是,提取成功值并递归,否则返回
never。
我对 Awaited 的理解总结
读完这几行源码之后,我对 Awaited<T> 的理解基本可以提炼为四个关键词:
null/undefined:保持原样,兼顾运行时行为和编译配置。
Thenable:不只识别 Promise<T>,而是识别带 then 方法的对象。
infer:先推导 then 的成功回调,再推导成功回调的第一个参数。
递归:如果解出来的值还是 Promise-like,就继续解,直到得到最终值。
再把源码放回来,其实就没那么陌生了:
ts
/**
* Recursively unwraps the "awaited type" of a type. Non-promise "thenables" should resolve to `never`. This emulates the behavior of `await`.
*/
type Awaited<T> = T extends null | undefined ? T : // special case for `null | undefined` when not in `--strictNullChecks` mode
T extends object & { then(onfulfilled: infer F, ...args: infer _): any; } ? // `await` only unwraps object types with a callable `then`. Non-object types are not unwrapped
F extends ((value: infer V, ...args: infer _) => any) ? // if the argument to `then` is callable, extracts the first argument
Awaited<V> : // recursively unwrap the value
never : // the argument to `then` was not callable
T; // non-object or non-thenable