《TypeScript编程入门》14.2 Promise 与 async/await 的类型

本节把 Promise 当成带类型参数的状态机来拆解:Promise<T> 里的 T 描述什么,then、catch、finally 如何传递与拓宽类型,async 函数为什么必须返回 Promise<T>,await 又做了怎样的解包。我们用表格对比 all、allSettled、race、any 的类型差异,讲清串行与并行 await 的性能陷阱,并给出类型安全的请求封装写法。

本节目标:读完这一节,你能说清 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.allPromise<Awaited<T>[]>结果数组取第一个失败是(首个 reason,unknown)
Promise.allSettledPromise<PromiseSettledResult<Awaited<T>>[]>逐项 { status: "fulfilled"; value }逐项 { status: "rejected"; reason }否,永不 reject
Promise.racePromise<Awaited<T>>最快完成的那个最快失败的也算「完成」取决于最快者
Promise.anyPromise<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 并发控制、取消与超时 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes