TypeScript 中 async 函数和 Promise 的类型标注

1. 概览

async 函数出现在几乎所有现代 TypeScript 代码中:调用 API、读取数据库、处理消息队列。写一个 async 函数很简单。但要为它声明类型,让编译器捕获错误就更难了。

这篇文章介绍如何为 async 函数和 Promise 进行类型标注。主要内容包括 Promise、Awaited、Promise.all 和 Promise.allSettled 的类型、类型安全的错误处理,以及像超时这样的 generic helper。读完这篇,你将避免常见的错误,比如忘记 await 或在 forEach 中使用 async。

2. 准备环境

示例使用启用 strict 模式的 TypeScript 和 Vitest 进行测试:

npm init -y
npm install -D typescript vitest
npx tsc --init --strict --target es2022 --module nodenext

--strict 标志同时启用 useUnknownInCatchVariables。这个标志对于第 5 节的错误处理非常重要。

所有示例共享一个简单的模型和一个模拟网络延迟的函数:

// models.ts
export interface Order {
  id: string;
  customerId: string;
  total: number;
  status: 'PENDING' | 'PAID';
}

export interface Customer {
  id: string;
  name: string;
}

export const delay = (ms: number) =>
  new Promise<void>((resolve) => setTimeout(resolve, ms));

delay 返回 Promise<void>。这是一个 Promise 的类型,表示“已完成”但不携带值。这里必须明确写 new Promise<void>。如果不写,TypeScript 会推断为 Promise<unknown>。

3. async 函数的返回类型

3.1. 总是 Promise<T>

async 函数 总是 返回一个 Promise。你在函数内部 return 的值会被包装进去:

// order-service.ts
import { Order, Customer, delay } from './models';

const orders = new Map<string, Order>([
  ['o-1', { id: 'o-1', customerId: 'c-1', total: 250_000, status: 'PENDING' }],
]);

export async function fetchOrder(id: string): Promise<Order> {
  await delay(50);
  const order = orders.get(id);
  if (!order) {
    throw new Error(`订单 ${id} 未找到`);
  }
  return order; // 类型是 Order,被包装成 Promise<Order>
}

export async function fetchCustomer(id: string): Promise<Customer> {
  await delay(30);
  return { id, name: 'Nguyễn Văn An' };
}

声明的类型是 Promise<Order> 而不是 Order。如果你写 async function fetchOrder(id: string): Order,编译器会报错:

error TS1064: The return type of an async function or method must be the global Promise<T> type.

函数内部的 throw 语句不会改变返回类型。错误被转换为一个被拒绝的 Promise。调用者通过 await 周围的 try/catch 或使用 .catch() 来捕获它。

3.2. 推断还是显式声明?

如果你省略类型注释,TypeScript 会自动推断返回类型。不过,对于 public 函数,你应该显式写出类型。看下面的例子:

async function findOrder(id: string) {
  const order = orders.get(id);
  if (!order) return null;
  return order;
}
// 推断的类型:Promise<Order | null>

这个类型是对的,但它取决于函数体。假设有人把第一个分支改成 return undefined。类型会悄悄变成 Promise<Order | undefined>,所有检查 === null 的地方都会出错,却没有任何警告。当你显式写 Promise<Order | null> 时,编译器会立即在函数处报错。

4. 用 Awaited<T> 获取 Promise 内部的类型

有时你有一个 async 函数,需要获取 await 之后的值的类型。ReturnType 不够用,因为它返回 Promise 本身:

import { fetchOrder } from './order-service';

type R1 = ReturnType<typeof fetchOrder>;          // Promise<Order>
type R2 = Awaited<ReturnType<typeof fetchOrder>>; // Order

type Nested = Awaited<Promise<Promise<number>>>;  // number

Awaited<T> 模拟了 await 的确切行为。它递归地“打开” Promise,即使 Promise 嵌套在一起。这个类型在你写 generic 代码或者库没有导出结果类型时非常有用:

type OrderSummary = Pick<Awaited<ReturnType<typeof fetchOrder>>, 'id' | 'total'>;
// { id: string; total: number }

5. 错误的类型:Promise 没有错误参数

Promise<T> 只有 一个 类型参数,用于成功时的值。没有类似 Promise<Order, NotFoundError> 的语法。因此 catch 块中的变量类型是 unknown:

async function printOrder(id: string): Promise<void> {
  try {
    const order = await fetchOrder(id);
    console.log(order.total);
  } catch (err) {
    // err: unknown
    if (err instanceof Error) {
      console.error('错误:', err.message);
    } else {
      console.error('未知错误:', err);
    }
  }
}

await printOrder('o-404');
错误:订单 o-404 未找到

JavaScript 允许 throw 任何值:字符串、数字、对象。因此 unknown 是 err 唯一真实的类型。你必须在访问 err.message 之前用 instanceof 缩小类型。

如果你想让错误成为类型的一部分,可以使用 Result 模式:

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

async function attempt<T>(promise: Promise<T>): Promise<Result<T>> {
  try {
    return { ok: true, value: await promise };
  } catch (err) {
    const error = err instanceof Error ? err : new Error(String(err));
    return { ok: false, error };
  }
}

const result = await attempt(fetchOrder('o-1'));
if (result.ok) {
  console.log(result.value.status); // result.value: Order
} else {
  console.log(result.error.message);
}
PENDING

属性 ok 是一个 discriminant。在 if (result.ok) 检查之后,TypeScript 确定 result.value 存在并且类型是 Order。调用者不可能“忘记”处理错误,因为要访问 value 就必须先检查 ok。

注意: 不要用 Result 包装所有函数。这个模式适合预期的业务错误,比如“未找到”或“库存不足”。对于基础设施错误(如数据库连接丢失),让异常向上传播到中央错误处理层。

6. Promise.all、allSettled 和 race 的类型

6.1. Promise.all 保持 tuple 类型

当你传递一个数组字面量时,TypeScript 为 每个位置 推断类型:

const [order, customer] = await Promise.all([
  fetchOrder('o-1'),
  fetchCustomer('c-1'),
]);
// order: Order, customer: Customer

console.log(`${customer.name} - ${order.total}`);
Nguyễn Văn An - 250000

两个请求并行运行。总耗时约 50ms 而不是 80ms。结果类型是 [Order, Customer],而不是 (Order | Customer)[]。正因为此,你可以解构而无需类型转换。

这仅在你直接传递数组时有效。如果先创建数组再传入,tuple 类型会丢失:

const tasks = [fetchOrder('o-1'), fetchCustomer('c-1')];
const results = await Promise.all(tasks);
// results: (Order | Customer)[]  -- 位置信息丢失了

const tasksTuple = [fetchOrder('o-1'), fetchCustomer('c-1')] as const;
const [o, c] = await Promise.all(tasksTuple); // Order, Customer

添加 as const 有助于保留 tuple 类型。

6.2. Promise.allSettled 和类型缩小

Promise.allSettled 不会 reject。结果中的每个元素都有类型 PromiseSettledResult<T>:

const settled = await Promise.allSettled([
  fetchOrder('o-1'),
  fetchOrder('o-404'),
]);

for (const r of settled) {
  if (r.status === 'fulfilled') {
    console.log('成功', r.value.id);    // r.value: Order
  } else {
    console.log('失败', r.reason);    // r.reason: any
  }
}
成功 o-1
失败 Error: 订单 o-404 未找到

r.reason 的类型是 any 而不是 unknown。这是标准库中的一个小漏洞,所以在使用之前你仍需检查 instanceof Error。

6.3. Promise.race 和通用的超时 helper

Promise.race 返回各个 Promise 类型的并集。结合 Promise<never>,我们可以写一个通用的超时 helper,保留类型:

export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
  const timeout = new Promise<never>((_, reject) =>
    setTimeout(() => reject(new Error(`${ms}ms 后超时`)), ms),
  );
  return Promise.race([promise, timeout]);
}

const order = await withTimeout(fetchOrder('o-1'), 1000); // order: Order

因为 timeout 类型是 Promise<never>,race 的结果是 Promise<T | never>,也就是 Promise<T>。never 从并集中消失了。这正是它的含义:“这个分支永远不会返回一个值”。

注意: 上面的例子在 promise 先完成时不会取消 setTimeout。在生产代码中,你应该保存定时器 id 并在完成时调用 clearTimeout。另一种方法是使用 AbortSignal.timeout(ms)(如果底层 API 支持 signal)。

7. async 回调的类型

7.1. () => void 的陷阱

TypeScript 允许把返回 Promise<void> 的函数赋给 () => void 类型。这看起来无害,但可能隐藏错误:

function onOrderPaid(handler: (order: Order) => void) {
  const order: Order = { id: 'o-1', customerId: 'c-1', total: 250_000, status: 'PAID' };
  handler(order); // Promise 被忽略了
  console.log('已调用 handler');
}

onOrderPaid(async (order) => {
  await delay(10);
  throw new Error('发送邮件失败'); // unhandled rejection!
});
已调用 handler
Error: 发送邮件失败   (unhandled rejection,可能导致 Node 进程崩溃)

onOrderPaid 不知道 handler 是 async 的。它不 await,所以错误没人捕获。修复方法是显式说明 handler 可以是 async,然后 await 它:

type OrderHandler = (order: Order) => void | Promise<void>;

async function onOrderPaidSafe(handler: OrderHandler): Promise<void> {
  const order: Order = { id: 'o-1', customerId: 'c-1', total: 250_000, status: 'PAID' };
  await handler(order); // await 非 Promise 的值也是合法的
}

类型 void | Promise<void> 接受同步和异步 handler。在非 Promise 值上 await 只是返回该值。

7.2. forEach 中的 async

这是最常见的错误。forEach 接受 (item) => void,所以它忽略 Promise:

async function payAll(list: Order[]): Promise<void> {
  list.forEach(async (o) => {
    await delay(20);
    o.status = 'PAID';
  });
  console.log(list.map((o) => o.status)); // 仍是 ['PENDING', ...]
}

有两种修复方式,取决于你的需求:

// 顺序:一次一个
for (const o of list) {
  await delay(20);
  o.status = 'PAID';
}

// 并行:返回类型是 Promise<void[]>
await Promise.all(
  list.map(async (o) => {
    await delay(20);
    o.status = 'PAID';
  }),
);

要自动捕获这些类型错误,请在 typescript-eslint 中启用 @typescript-eslint/no-floating-promises 和 @typescript-eslint/no-misused-promises 规则。单独的 TypeScript 编译器无法捕获这些情况。

8. 用 Vitest 测试行为和类型

Vitest 允许你同时测试运行时值和编译时类型:

// order-service.test.ts
import { describe, it, expect, expectTypeOf } from 'vitest';
import { fetchOrder, fetchCustomer } from './order-service';
import { withTimeout } from './with-timeout';
import { delay, type Order } from './models';

describe('order-service', () => {
  it('订单存在时,返回订单', async () => {
    await expect(fetchOrder('o-1')).resolves.toMatchObject({
      id: 'o-1',
      status: 'PENDING',
    });
  });

  it('订单不存在时,reject', async () => {
    await expect(fetchOrder('o-404')).rejects.toThrow('订单 o-404 未找到');
  });

  it('promise 耗时过长时,withTimeout 会 reject', async () => {
    const slow = delay(200).then(() => '完成');
    await expect(withTimeout(slow, 50)).rejects.toThrow('50ms 后超时');
  });

  it('为 Promise.all 和 withTimeout 保持正确的类型', () => {
    expectTypeOf(fetchOrder).returns.resolves.toEqualTypeOf<Order>();
    expectTypeOf(withTimeout(fetchOrder('o-1'), 100)).resolves.toEqualTypeOf<Order>();
    expectTypeOf(
      Promise.all([fetchOrder('o-1'), fetchCustomer('c-1')]),
    ).resolves.toMatchTypeOf<[Order, { name: string }]>();
  });
});

运行测试:

npx vitest run --typecheck
 ✓ order-service.test.ts (4 tests)
 Test Files  1 passed (1)
      Tests  4 passed (4)

有三点需要注意:

  • 始终 await 在 expect(...).resolves/.rejects 之前。如果遗漏,测试在 Promise 完成前就结束了,总是通过。
  • .rejects.toThrow(...) 检查被拒绝错误的消息。
  • expectTypeOf 在运行时不做任何事。--typecheck 标志告诉 Vitest 运行 tsc 来验证类型。如果有人改变 withTimeout 返回 Promise<unknown>,这个测试就会失败。

9. 常见错误

错误 后果 修复
忘记 await 调用 async 函数 变量类型是 Promise<Order> 而不是 Order;错误被吞掉 启用 no-floating-promises;悬停时仔细阅读类型
if (fetchOrder(id)) 总是 true,因为 Promise 是对象 TypeScript 5.x 报告条件总是真;await 之后再检查
forEach 中的 async 不等待,错误不被捕获 使用 for...of 或 Promise.all(map)
在 catch 中访问 err.message 启用 strict 时编译错误 用 instanceof Error 缩小类型
对来自 fetch 的数据进行类型转换 as Promise<Order> 类型“说谎”,错误在运行时出现 用 schema 验证数据,然后再赋予类型

最后一个错误需要补充说明。await response.json() 返回 Promise<any>。写 const order: Order = await res.json() 可以编译,但 TypeScript 不检查任何东西。来自进程外的数据需要被验证。

注意: 不是所有返回 Promise 的函数都需要 async 关键字。如果函数只是转发一个 Promise,比如 return repository.find(id),你可以省略 async。保留 async 仅在函数内有围绕 await 的 try/catch 时。如果在这种情况下省略 async 和 await,catch 将无法捕获被拒绝的错误。

10. 总结

async 函数总是返回 Promise<T>。为 public API 明确声明这个类型。当需要获取“打开”后的 Promise 类型时,使用 Awaited<T>。如果你传递数组字面量或使用 as const,Promise.all 保留 tuple 类型。结合 Promise.race 和 Promise<never> 编写不丢失类型的通用 helper。

关于错误,Promise 不携带错误类型。catch 中的变量是 unknown,而 Result 模式在你希望业务错误在类型中清晰可见时是一个不错的选择。最后,小心处理 () => void 类型的回调和 forEach。启用 ESLint 中关于 Promise 的规则来自动捕获编译器遗漏的错误。

typescriptasyncpromisegenericsvitest