TypeScript 中 async 函数和 Promise 的类型标注
1. 概览
async 函数出现在几乎所有现代 TypeScript 代码中:调用 API、读取数据库、处理消息队列。写一个 async 函数很简单。但要为它声明类型,让编译器捕获错误就更难了。
这篇文章介绍如何为 async 函数和 Promise 进行类型标注。主要内容包括 Promise
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 的规则来自动捕获编译器遗漏的错误。