Kiểu cho hàm async và Promise trong TypeScript
1. Tổng quan
Hàm async có mặt ở hầu hết code TypeScript hiện đại: gọi API, đọc database, xử lý hàng đợi. Viết một hàm async thì dễ. Khai báo kiểu cho nó để compiler bắt được lỗi thì khó hơn.
Bài này trình bày cách gán kiểu cho hàm async và Promise. Các nội dung chính gồm Promise<T>, Awaited<T>, kiểu của Promise.all và Promise.allSettled, cách xử lý lỗi có kiểu, và các helper generic như timeout. Đọc xong, bạn sẽ tránh được những lỗi khó thấy như quên await hay dùng async trong forEach.
2. Chuẩn bị môi trường
Các ví dụ dùng TypeScript ở chế độ strict và Vitest để test:
npm init -y
npm install -D typescript vitest
npx tsc --init --strict --target es2022 --module nodenext
Cờ --strict bật luôn useUnknownInCatchVariables. Cờ này quan trọng cho phần xử lý lỗi ở mục 5.
Mọi ví dụ dùng chung một model đơn giản và một hàm giả lập độ trễ mạng:
// 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 trả về Promise<void>. Đây là kiểu của một Promise chỉ báo “đã xong” mà không mang giá trị. Ở đây phải ghi rõ new Promise<void>. Nếu không ghi, TypeScript suy ra Promise<unknown>.
3. Kiểu trả về của hàm async
3.1. Luôn là Promise<T>
Hàm async luôn trả về một Promise. Giá trị bạn return bên trong sẽ được bọc vào đó:
// 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(`Order ${id} not found`);
}
return order; // kiểu Order, được bọc thành Promise<Order>
}
export async function fetchCustomer(id: string): Promise<Customer> {
await delay(30);
return { id, name: 'Nguyễn Văn An' };
}
Kiểu khai báo là Promise<Order> chứ không phải Order. Nếu bạn viết async function fetchOrder(id: string): Order, compiler báo lỗi:
error TS1064: The return type of an async function or method must be the global Promise<T> type.
Lệnh throw bên trong hàm async không làm thay đổi kiểu trả về. Lỗi được chuyển thành một Promise bị reject. Bên gọi bắt nó bằng try/catch quanh await, hoặc bằng .catch().
3.2. Suy luận hay khai báo tường minh?
TypeScript tự suy ra kiểu trả về nếu bạn bỏ phần chú thích. Dù vậy, với các hàm public bạn nên ghi rõ kiểu. Xem ví dụ sau:
async function findOrder(id: string) {
const order = orders.get(id);
if (!order) return null;
return order;
}
// Kiểu suy ra: Promise<Order | null>
Kiểu này đúng, nhưng nó phụ thuộc vào phần thân hàm. Giả sử ai đó sửa nhánh đầu thành return undefined. Kiểu sẽ âm thầm đổi thành Promise<Order | undefined>, và mọi chỗ gọi đang kiểm tra === null sẽ sai mà không có cảnh báo nào. Khi ghi Promise<Order | null> tường minh, compiler báo lỗi ngay tại hàm đó.
4. Lấy kiểu “bên trong” Promise với Awaited<T>
Đôi khi bạn có một hàm async và cần kiểu của giá trị sau khi await. ReturnType chưa đủ, vì nó trả về chính 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> mô phỏng đúng hành vi của await. Nó “mở” Promise một cách đệ quy, kể cả khi các Promise lồng nhau. Kiểu này rất hữu ích khi bạn viết generic, hoặc khi một thư viện không export kiểu kết quả:
type OrderSummary = Pick<Awaited<ReturnType<typeof fetchOrder>>, 'id' | 'total'>;
// { id: string; total: number }
5. Kiểu cho lỗi: Promise không có tham số lỗi
Promise<T> chỉ có một tham số kiểu, dành cho giá trị khi thành công. Không có cú pháp kiểu Promise<Order, NotFoundError>. Vì vậy biến trong catch có kiểu 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('Lỗi:', err.message);
} else {
console.error('Lỗi không xác định:', err);
}
}
}
await printOrder('o-404');
Lỗi: Order o-404 not found
JavaScript cho phép throw bất kỳ giá trị nào: chuỗi, số, object. Do đó unknown là kiểu trung thực duy nhất cho err. Bạn phải thu hẹp kiểu bằng instanceof trước khi truy cập err.message.
Nếu muốn lỗi trở thành một phần của kiểu, bạn có thể dùng mẫu 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
Thuộc tính ok là một discriminant. Sau khi kiểm tra if (result.ok), TypeScript biết chắc result.value tồn tại và có kiểu Order. Bên gọi không thể “quên” xử lý lỗi, vì muốn lấy value thì phải kiểm tra ok trước.
Lưu ý: Đừng bọc mọi hàm bằng
Result. Mẫu này hợp với lỗi nghiệp vụ dự kiến trước, như “không tìm thấy” hay “hết hàng”. Với lỗi hạ tầng như mất kết nối DB, cứ để exception lan lên tầng xử lý chung.
6. Kiểu của Promise.all, allSettled và race
6.1. Promise.all giữ nguyên kiểu tuple
Khi truyền một mảng literal, TypeScript suy ra kiểu cho từng vị trí:
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
Hai request chạy song song. Tổng thời gian khoảng 50ms chứ không phải 80ms. Kiểu kết quả là [Order, Customer], không phải (Order | Customer)[]. Nhờ vậy bạn destructure mà không cần ép kiểu.
Điều này chỉ đúng khi bạn truyền mảng trực tiếp. Nếu tạo mảng trước rồi mới truyền vào, kiểu tuple bị mất:
const tasks = [fetchOrder('o-1'), fetchCustomer('c-1')];
const results = await Promise.all(tasks);
// results: (Order | Customer)[] -- mất thông tin vị trí
const tasksTuple = [fetchOrder('o-1'), fetchCustomer('c-1')] as const;
const [o, c] = await Promise.all(tasksTuple); // Order, Customer
Thêm as const giúp giữ lại kiểu tuple.
6.2. Promise.allSettled và thu hẹp kiểu
Promise.allSettled không reject. Mỗi phần tử trong kết quả có kiểu PromiseSettledResult<T>:
const settled = await Promise.allSettled([
fetchOrder('o-1'),
fetchOrder('o-404'),
]);
for (const r of settled) {
if (r.status === 'fulfilled') {
console.log('OK', r.value.id); // r.value: Order
} else {
console.log('FAIL', r.reason); // r.reason: any
}
}
OK o-1
FAIL Error: Order o-404 not found
r.reason có kiểu any, không phải unknown. Đây là một lỗ hổng nhỏ trong thư viện chuẩn, nên bạn vẫn cần kiểm tra instanceof Error trước khi dùng.
6.3. Promise.race và helper timeout generic
Promise.race trả về kiểu hợp của các Promise. Kết hợp với Promise<never>, ta có một helper timeout giữ nguyên kiểu:
export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`Timeout sau ${ms}ms`)), ms),
);
return Promise.race([promise, timeout]);
}
const order = await withTimeout(fetchOrder('o-1'), 1000); // order: Order
Vì timeout có kiểu Promise<never>, kết quả của race là Promise<T | never>, tức là Promise<T>. never biến mất khỏi kiểu hợp. Đây chính là ý nghĩa của nó: “nhánh này không bao giờ trả về giá trị”.
Lưu ý: Ví dụ trên không hủy
setTimeoutkhipromisexong trước. Trong code production, bạn nên lưu id của timer và gọiclearTimeouttrongfinally. Cách khác là dùngAbortSignal.timeout(ms)nếu API bên dưới hỗ trợsignal.
7. Kiểu cho callback async
7.1. Bẫy () => void
TypeScript cho phép gán một hàm trả về Promise<void> vào kiểu () => void. Điều này trông vô hại nhưng có thể làm mất lỗi:
function onOrderPaid(handler: (order: Order) => void) {
const order: Order = { id: 'o-1', customerId: 'c-1', total: 250_000, status: 'PAID' };
handler(order); // Promise trả về bị bỏ qua
console.log('Đã gọi xong handler');
}
onOrderPaid(async (order) => {
await delay(10);
throw new Error('Gửi email thất bại'); // unhandled rejection!
});
Đã gọi xong handler
Error: Gửi email thất bại (unhandled rejection, có thể làm crash process Node)
onOrderPaid không biết handler là async. Nó không await, nên lỗi không được ai bắt. Cách sửa là khai báo rõ rằng handler có thể là async, rồi await nó:
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 một giá trị không phải Promise vẫn hợp lệ
}
Kiểu void | Promise<void> nhận được cả handler đồng bộ lẫn bất đồng bộ. await trên giá trị đồng bộ chỉ trả lại chính giá trị đó.
7.2. async trong forEach
Đây là lỗi phổ biến nhất. forEach nhận (item) => void nên bỏ qua 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)); // vẫn ['PENDING', ...]
}
Có hai cách sửa, tùy nhu cầu:
// Tuần tự: từng đơn một
for (const o of list) {
await delay(20);
o.status = 'PAID';
}
// Song song: kiểu trả về là Promise<void[]>
await Promise.all(
list.map(async (o) => {
await delay(20);
o.status = 'PAID';
}),
);
Để bắt các lỗi kiểu này tự động, bạn hãy bật rule @typescript-eslint/no-floating-promises và @typescript-eslint/no-misused-promises trong typescript-eslint. Compiler TypeScript một mình không báo lỗi cho các trường hợp này.
8. Test hành vi và kiểu với Vitest
Vitest cho phép test cả giá trị lúc runtime lẫn kiểu lúc compile:
// 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('trả về đơn hàng khi tồn tại', async () => {
await expect(fetchOrder('o-1')).resolves.toMatchObject({
id: 'o-1',
status: 'PENDING',
});
});
it('reject khi không tìm thấy đơn', async () => {
await expect(fetchOrder('o-404')).rejects.toThrow('Order o-404 not found');
});
it('withTimeout reject khi promise chạy quá lâu', async () => {
const slow = delay(200).then(() => 'xong');
await expect(withTimeout(slow, 50)).rejects.toThrow('Timeout sau 50ms');
});
it('giữ đúng kiểu cho Promise.all và 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 }]>();
});
});
Chạy test:
npx vitest run --typecheck
✓ order-service.test.ts (4 tests)
Test Files 1 passed (1)
Tests 4 passed (4)
Có ba điểm cần chú ý:
- Luôn
awaittrướcexpect(...).resolves/.rejects. Nếu thiếu, test kết thúc trước khi Promise xong và luôn pass. .rejects.toThrow(...)kiểm tra message của lỗi bị reject.expectTypeOfkhông chạy logic gì lúc runtime. Cờ--typecheckyêu cầu Vitest chạytscđể xác nhận kiểu. Nếu ai đó đổiwithTimeouttrả vềPromise<unknown>, test này fail.
9. Lỗi hay gặp
| Lỗi | Hậu quả | Cách sửa |
|---|---|---|
Quên await khi gọi hàm async |
Biến có kiểu Promise<Order> thay vì Order; lỗi bị nuốt |
Bật no-floating-promises; đọc kỹ kiểu khi hover |
if (fetchOrder(id)) |
Luôn true vì Promise là object |
TypeScript 5.x báo lỗi điều kiện luôn đúng; hãy await trước |
async trong forEach |
Không chờ, lỗi không được bắt | for...of hoặc Promise.all(map) |
Truy cập err.message trong catch |
Lỗi compile khi bật strict |
Thu hẹp bằng instanceof Error |
Ép kiểu as Promise<Order> cho dữ liệu từ fetch |
Kiểu “nói dối”, lỗi xuất hiện lúc runtime | Validate dữ liệu bằng schema rồi mới gán kiểu |
Lỗi cuối cùng cần nói thêm. await response.json() trả về Promise<any>. Viết const order: Order = await res.json() biên dịch được, nhưng TypeScript không kiểm tra gì cả. Dữ liệu từ bên ngoài process luôn cần được validate.
Lưu ý: Không phải hàm nào trả về Promise cũng cần từ khóa
async. Nếu hàm chỉ chuyển tiếp một Promise, ví dụreturn repository.find(id), bạn có thể bỏasync. Hãy giữasynckhi trong hàm cótry/catchquanhawait. Nếu bỏasyncvàawaitở trường hợp đó,catchsẽ không bắt được lỗi reject.
10. Kết luận
Hàm async luôn trả về Promise<T>. Hãy khai báo kiểu này tường minh cho các API public. Dùng Awaited<T> khi cần lấy kiểu đã “mở” Promise. Promise.all giữ kiểu tuple nếu bạn truyền mảng literal hoặc dùng as const. Kết hợp Promise.race với Promise<never> để viết helper generic không làm mất kiểu.
Về lỗi, Promise không mang kiểu của lỗi. Biến trong catch là unknown, và mẫu Result là lựa chọn khi bạn muốn lỗi nghiệp vụ hiện rõ trong kiểu. Cuối cùng, hãy cẩn thận với callback kiểu () => void và forEach. Bật các rule ESLint về Promise để tự động bắt những lỗi mà compiler bỏ qua.