深入源码理解 TypeScript 内置工具类型 Awaited<Type> 的原理

深入源码理解 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 开始引入,用来描述 awaitPromise.then 这类操作递归展开 Promise 的结果类型。

我们先不看官方注释,先把整体逻辑翻译成一句话:

如果 Tnullundefined,保持原样;否则如果 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 的结果就是 nullawait undefined 的结果就是 undefined。它们不是 Promise,也没什么可解包的。

从类型系统看,这里还和 strictNullChecks 有关。TypeScript 源码注释里把它称为一个 special case:在非严格空值检查模式下,nullundefined 的关系更宽松,如果不提前处理,后面的对象判断可能出现不符合预期的推导。

这个细节很小,但挺能说明问题:内置工具类型和我们平时写业务类型不太一样,它必须照顾很多编译配置和历史兼容性。

第二段:为什么不是判断 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 分支,最终会返回原类型。

这两个例子的差别很小,但正好能说明源码判断的层次:

  1. 先看是不是对象,并且有没有可调用的 then
  2. 再看 then 的第一个参数是不是可调用的成功回调;
  3. 如果是,提取成功值并递归,否则返回 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

参考资料

相关推荐
7yue6 小时前
用 TypScript 学习 Claude Code
前端·typescript·claude
小lan猫7 小时前
多域 RAG 知识库:从 Vue 前端到 NestJS + PGVector 的全栈实践
前端·人工智能·typescript
梦想的颜色18 小时前
TypeScript 完全指南(下):从类型体操到生产级配置
前端·javascript·typescript
老毛肚1 天前
jeecgboot vue 路由 拆分01
前端·javascript·typescript
艾利克斯冰2 天前
TypeScript 静态类型入门教程:可选静态类型与类型推导详
前端·javascript·typescript
cup114 天前
[Full Clock 技术复盘] 一、浏览器前端如何实现百毫秒级时间校准?时间 API 推荐、模拟 NTP 算法原理及局限
typescript·开源·api·时钟·时间同步
梦想的颜色4 天前
TypeScript 完全指南(上):从零开始掌握类型系统
前端·typescript
烛衔溟4 天前
TypeScript 类的静态成员与静态方法
开发语言·javascript·typescript