本节目标:读完这一节,你能说清
Promise<T>里的T只描述成功值、不描述失败原因;能预测then/catch/finally对返回类型的收窄与拓宽;能解释async函数为什么必须返回Promise<T>;能用Awaited<T>手动解包嵌套 Promise;能背出all/allSettled/race/any四者的类型差异与失败语义;并且能识别「忘了 await」和「在 try 里 return promise」这两个最常见的异步坑。
14.2 Promise 与 async/await 的类型
上一节我们把「失败」搬进了类型。但真实项目里的失败几乎都发生在异步路径上:请求超时、连接被重置、服务端返回 500。要谈这些,先得把 Promise 的类型参数看透。
很多同学写了两年前端,对 Promise<T> 的直觉仍然是「里面装了个 T」。这个直觉只对了一半——Promise 的 T 只描述成功时的值,失败那一侧在类型上是敞开的。这一节就从这里开始。
Promise 是一个带类型参数的状态机
从类型角度看,一个 Promise 有三种状态,但泛型参数只有一个:
// 概念示意:Promise<T> 的 T 只覆盖 fulfilled 分支
type PromiseStates<T> =
| { status: "pending" }
| { status: "fulfilled"; value: T }
| { status: "rejected"; reason: unknown }; // ← 注意这里是 unknown
Promise<T> 的 T 对应的是 fulfilled 时的 value。而 rejected 时的 reason 没有出现在类型参数里——这与 14.1 讲的 catch (e) 是 unknown 是同一个根源:JavaScript 允许 reject 任何值,类型系统无法替运行时保证。
const p1: Promise<number> = Promise.resolve(42);
const p2: Promise<number> = new Promise((resolve) => resolve(42));
// ❌ 泛型参数是契约,构造时必须兑现
const p3 = new Promise<number>((resolve) => resolve("42"));
最后一行会报:
Argument of type 'string' is not assignable to parameter of type 'number | PromiseLike<number>'. ts(2345)
看 new Promise 的签名会更清楚——两个回调的参数类型是写死的:
interface PromiseConstructorExcerpt {
// lib.es2015.promise.d.ts 的简化版
new <T>(
executor: (
resolve: (value: T | PromiseLike<T>) => void,
reject: (reason?: any) => void,
) => void,
): Promise<T>;
}
resolve 接受 T | PromiseLike<T>,所以 resolve(Promise.resolve(42)) 也是合法的——Promise 会自动「吸收」内层的 thenable,这也是下一节 Awaited 能存在的原因。而 reject 的参数是 any,等于告诉你:失败原因不受类型系统约束,想约束就得自己用判别联合(见 14.1)。
then / catch / finally 如何传递类型
then 是链式推导的主力,它把上游的值类型喂给回调,再把回调的返回值包成新的 Promise:
const p = Promise.resolve(42);
const a = p.then((n) => n.toString()); // Promise<string>
const b = a.then((s) => s.length); // Promise<number>
回调返回一个 Promise 时,类型会自动扁平化,不会出现 Promise<Promise<number>>:
const c = p.then(async (n) => n * 2); // Promise<number>,不是 Promise<Promise<number>>
catch 有个容易被忽略的行为:它会把返回类型拓宽成联合。因为 catch 的回调既可能在成功时被跳过,也可能在失败时被调用:
const d = Promise.resolve(42).catch(() => 0);
// Promise<number> —— 两分支同类型,不变
const e = Promise.resolve(42).catch(() => "fallback");
// Promise<number | string> ← 成功值 42 与兜底值 "fallback" 的联合
很多人以为 catch 之后类型会「变成」兜底值的类型,于是写下 const n: string = await e 之类的代码,结果报 Type 'number' is not assignable to type 'string'。记住:catch 只能保证失败时返回兜底值,它无法消除成功分支。
finally 则完全不碰类型,只是插入一个副作用:
const f = Promise.resolve(42).finally(() => {
console.log("无论成败都执行");
}); // Promise<number>,类型不变
async 函数:返回类型总是 Promise
async 函数的返回类型有一个硬规则:必须写成 Promise<T>,哪怕函数体里 return 的是裸值。
async function getUser(id: number) {
return { id, name: "Ada" };
}
// 推断为 Promise<{ id: number; name: string }>
async function getUser2(id: number): Promise<{ id: number; name: string }> {
return { id, name: "Ada" }; // 直接返回对象,编译器自动包 Promise
}
写成裸类型会直接报错:
// ❌
async function bad(): number {
return 1;
}
The return type of an async function or method must be the global Promise<T> type.
Did you mean to write 'Promise<number>'? ts(1064)
同理,把返回类型写成 Promise<Promise<number>> 也是错的——async 会自动扁平化一层,编译器会提示你改成 Promise<number>。这个「自动扁平化」和上一节的 PromiseLike 吸收是同一套机制。
一个实务建议:导出的 API 函数显式标注返回类型。推断虽然聪明,但一旦有人改动函数体,返回类型会悄悄漂移,而调用方的契约就此被破坏。显式标注能让编译器在你改错时立刻拦住。
await 做了什么:Awaited 与解包
await 在类型层面做的事,可以精确地用标准库工具类型 Awaited<T> 描述:
type A = Awaited<Promise<number>>; // number
type B = Awaited<Promise<Promise<string>>>; // string(递归解包)
type C = Awaited<number>; // number(非 Promise 原样返回)
type D = Awaited<Promise<Promise<boolean>>>; // boolean
也就是说,await p 的类型等价于 Awaited<typeof p>:
async function main() {
const n = await Promise.resolve(42); // number
const s = await Promise.resolve("hi"); // string
const u = await Promise.resolve(undefined); // undefined
// 注意:await 不会改变 null / undefined 的存在性
}
await 不只对 Promise 有效,任何带 then 方法的对象(thenable)都能被 await,其类型同样走 Awaited 的规则。这也是很多数据库驱动、ORM 的 QueryBuilder 能被直接 await 的原因——它们实现了 then 接口,但类型上并不 extends Promise。
四个组合子的类型对比
这是最容易记混的一块,用一张表锁定:
| 方法 | 简化签名 | 全部成功时 | 有失败时 | 是否 reject |
|---|---|---|---|---|
Promise.all | Promise<Awaited<T>[]> | 结果数组 | 取第一个失败 | 是(首个 reason,unknown) |
Promise.allSettled | Promise<PromiseSettledResult<Awaited<T>>[]> | 逐项 { status: "fulfilled"; value } | 逐项 { status: "rejected"; reason } | 否,永不 reject |
Promise.race | Promise<Awaited<T>> | 最快完成的那个 | 最快失败的也算「完成」 | 取决于最快者 |
Promise.any | Promise<Awaited<T>> | 第一个成功值 | 全部失败才 reject | 是(AggregateError) |
Promise.all 传入元组时会保留元组结构,这是它最实用的特性:
declare function fetchA(): Promise<string>;
declare function fetchB(): Promise<number>;
const [a, b] = await Promise.all([fetchA(), fetchB()]);
// a: string, b: number ← 位置类型被完整保留
allSettled 返回的每一项都是判别联合,必须按 status 分流才能取值——这是它「永不 reject」的代价:
const results = await Promise.allSettled([fetchA(), fetchB()]);
for (const r of results) {
if (r.status === "fulfilled") {
console.log(r.value); // string | number
} else {
console.error(r.reason); // unknown,需自己归一化
}
}
any 与 race 的差别在于「失败算不算完成」:race 里第一个 reject 会立刻让整体失败,any 则会继续等别人成功。
try {
const first = await Promise.any([mirrorA(), mirrorB()]);
} catch (e) {
// 只有全部镜像都失败才会走到这里
if (e instanceof AggregateError) {
console.error(e.errors); // unknown[]
}
}
try/catch 在 async 里的坑
async 函数里的 try/catch 与同步版一致,catch 的参数仍是 unknown:
async function load() {
try {
return await fetchJson();
} catch (e) {
// e: unknown —— 与 14.1 的结论完全相同
const err = e instanceof Error ? e : new Error(String(e));
throw err;
}
}
但有一个极其隐蔽的坑:在 try 里 return 一个未 await 的 Promise,异常不会被这个 catch 捕获。
async function loadBad() {
try {
return fetchJson(); // ❌ 少了 await
} catch (e) {
return null; // 永远不会执行
}
}
原因在于 return fetchJson() 会先退出 try 块(catch 的作用域随之结束),再由 async 函数自身的机制去等待这个 Promise。等 rejection 到达时,已经没有 catch 在监听了。修复方式就是补上 await,写成 return await fetchJson()——在 try/catch 包裹下,这个 await 是必需且正确的。
串行 await 与并行 await
类型正确不代表性能正确。下面两段代码类型完全一样,耗时可能差三倍:
// ❌ 串行:总耗时 ≈ t1 + t2 + t3
const a = await fetchA();
const b = await fetchB();
const c = await fetchC();
// ✅ 并行:总耗时 ≈ max(t1, t2, t3)
const [a2, b2, c2] = await Promise.all([fetchA(), fetchB(), fetchC()]);
原则是:只要几个请求之间没有数据依赖,就放进 Promise.all。只有当后一个请求需要前一个的结果时,串行才是必要的。
同样的陷阱出现在循环里:
// ❌ 每次迭代都等待
for (const id of ids) {
await save(id);
}
// ✅ 并发执行(注意并发数控制,见 14.3)
await Promise.all(ids.map((id) => save(id)));
一个真实工程示例:类型安全的请求封装
把本节与上一节的结论合起来,写一个薄薄的请求层:
class NetworkError extends Error {
constructor(
message: string,
readonly status: number,
readonly retryable: boolean,
) {
super(message);
this.name = "NetworkError";
}
}
async function request<T>(
url: string,
parse: (raw: unknown) => T,
): Promise<T> {
const res = await fetch(url);
if (!res.ok) {
throw new NetworkError(`HTTP ${res.status}`, res.status, res.status >= 500);
}
const raw: unknown = await res.json(); // res.json() 的类型是 any,主动收成 unknown
return parse(raw); // 在边界做校验,见 13.2
}
async function loadProfile(id: number): Promise<User> {
return request(`/api/users/${id}`, (raw) => UserSchema.parse(raw));
}
两个细节值得强调:
res.json()在lib.dom.d.ts里的返回类型是Promise<any>。直接const data = await res.json()会让any顺着调用链一路污染。主动标注成unknown再交给校验函数,是把any挡在边界外的标准做法。parse作为参数注入,让「网络层」与「校验层」解耦:换一个接口就换一个 schema,request本身不用改。校验方案可参考 13.2 Zod 模式验证与类型推导 。
调用方则回到 14.1 的异常分流:
try {
const user = await loadProfile(7);
render(user);
} catch (e) {
if (e instanceof NetworkError && e.retryable) {
scheduleRetry();
} else {
showError(e instanceof Error ? e.message : String(e));
}
}
常见坑与报错
坑一:把 async 的返回类型写成裸类型。 见上文 ts(1064)。记住「async 必包 Promise」。
坑二:忘了 await。 类型从 T 变成 Promise<T>,多数情况下编译器会立刻报错,但如果目标位置接受 unknown 或 any,就会静默通过,直到运行时拿到一个 [object Promise]:
console.log(await getUser(1)); // ✅ { id: 1, name: "Ada" }
console.log(getUser(1)); // ❌ Promise { <pending> }
在 strict 项目里建议开启 ESLint 的 @typescript-eslint/no-floating-promises,它专门抓「创建了 Promise 却不处理」的代码。
坑三:forEach 里 await 不起作用。
// ❌ forEach 不会等待回调返回的 Promise
ids.forEach(async (id) => {
await save(id);
});
console.log("done"); // 会先于 save 打印
// ✅ 用 for...of 或 Promise.all
for (const id of ids) {
await save(id);
}
坑四:在 try 里 return 未 await 的 Promise。 见上文 loadBad,catch 形同虚设。
坑五:以为 catch 能收窄返回类型。 catch 只会把结果拓宽成联合,见上文 Promise<number | string>。
坑六:未处理的 rejection 导致进程退出。 Node.js 中未捕获的 Promise rejection 默认会让进程以非零码退出。要么在边界统一 catch,要么注册 process.on("unhandledRejection", ...) 兜底(见既有专题 /nodejs-error-handling-logging/
)。
延伸阅读:异步并发的更多模式可参考 /typescript-async-concurrency-control/ 与 /nodejs-async-concurrency/ 。
到这里,单个异步调用的类型与陷阱已经清楚了。但真实系统里往往是几十个请求同时打出去,还伴随超时、取消与重试——那些是下一节 14.3 并发控制、取消与超时 的主题。
小结
Promise<T>的T只描述成功值;失败原因在类型上是敞开的(reject(reason?: any)),与catch (e: unknown)同源。then传递类型并自动扁平化嵌套 Promise;catch会把返回类型拓宽成「成功值 | 兜底值」的联合;finally不改变类型。async函数的返回类型必须是Promise<T>,裸类型或Promise<Promise<T>>都会报ts(1064)。await的类型等价于Awaited<T>,会递归解包;任何实现then的 thenable 都能被 await。- 四个组合子的关键差异:
all遇错即失败、allSettled永不 reject、race最快者定成败、any只认第一个成功。 - 类型正确不等于行为正确:串行
await、forEach里的await、try中未 await 的return,都是编译期抓不到、运行期才暴露的坑。
阅读导航:上一节:14.1 错误类型与 Result 模式 · 下一节:14.3 并发控制、取消与超时 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。