Type guard và assertion function trong TypeScript
1. Tổng quan
TypeScript thu hẹp kiểu (narrowing) rất tốt với typeof, instanceof hay ===. Khi logic kiểm tra phức tạp hơn và bạn tách nó ra một hàm riêng, compiler sẽ không còn “nhìn thấy” kết quả kiểm tra nữa.
Bài này trình bày hai công cụ giải quyết vấn đề đó. Đó là type guard (value is T) và assertion function (asserts value is T). Đọc xong, bạn sẽ biết cách viết chúng, kiểm tra dữ liệu unknown từ API, viết test cho chúng, và nhận ra những trường hợp chúng âm thầm nói dối compiler.
2. Narrowing có sẵn và giới hạn của nó
Ta dùng một domain đơn hàng làm ví dụ xuyên suốt:
type PendingOrder = { status: "pending"; id: string; total: number };
type PaidOrder = { status: "paid"; id: string; total: number; paidAt: Date };
type CancelledOrder = { status: "cancelled"; id: string; reason: string };
type Order = PendingOrder | PaidOrder | CancelledOrder;
function describe(order: Order): string {
if (order.status === "paid") {
return `Đơn ${order.id} thanh toán lúc ${order.paidAt.toISOString()}`;
}
return `Đơn ${order.id} đang ở trạng thái ${order.status}`;
}
Order là một discriminated union với trường phân biệt là status. Trong nhánh if, TypeScript biết order là PaidOrder, nên truy cập paidAt hợp lệ.
Giờ bạn muốn tái sử dụng điều kiện đó và tách nó ra một hàm:
function isPaid(order: Order): boolean {
return order.status === "paid";
}
function describe(order: Order): string {
if (isPaid(order)) {
return `Thanh toán lúc ${order.paidAt.toISOString()}`;
// ^^^^^^ lỗi
}
return "Chưa thanh toán";
}
error TS2339: Property 'paidAt' does not exist on type 'Order'.
Property 'paidAt' does not exist on type 'PendingOrder'.
Hàm isPaid trả về boolean. Kiểu boolean không mang thông tin gì về order, nên compiler không thu hẹp được kiểu. Type guard sinh ra để giải quyết đúng chỗ này.
3. User-defined type guard
3.1. Cú pháp value is T
Bạn chỉ cần đổi kiểu trả về từ boolean thành một type predicate:
function isPaidOrder(order: Order): order is PaidOrder {
return order.status === "paid";
}
function describe(order: Order): string {
if (isPaidOrder(order)) {
return `Thanh toán lúc ${order.paidAt.toISOString()}`;
}
// ở đây order: PendingOrder | CancelledOrder
return `Trạng thái: ${order.status}`;
}
console.log(describe({ status: "paid", id: "A1", total: 120, paidAt: new Date("2026-10-01") }));
console.log(describe({ status: "cancelled", id: "A2", reason: "Hết hàng" }));
Thanh toán lúc 2026-10-01T00:00:00.000Z
Trạng thái: cancelled
Giải thích:
order is PaidOrdernghĩa là: nếu hàm trả vềtruethìordercó kiểuPaidOrder.- Lúc runtime, hàm vẫn chỉ trả về
true/false. Phầnischỉ tồn tại ở compile time. - Ở nhánh
false, TypeScript loạiPaidOrderkhỏi union.ordertrở thànhPendingOrder | CancelledOrder. - Tên tham số trong predicate (
order) phải trùng với tên một tham số của hàm.
3.2. Lọc mảng với type guard
Type guard đặc biệt hữu ích với Array.prototype.filter, vì filter có overload nhận predicate:
const orders: Order[] = [
{ status: "pending", id: "A1", total: 50 },
{ status: "paid", id: "A2", total: 120, paidAt: new Date() },
{ status: "paid", id: "A3", total: 80, paidAt: new Date() },
];
const paidOrders = orders.filter(isPaidOrder); // PaidOrder[]
const revenue = paidOrders.reduce((sum, o) => sum + o.total, 0);
console.log(revenue);
200
paidOrders có kiểu PaidOrder[] chứ không phải Order[]. Nhờ vậy, mọi thao tác phía sau truy cập được paidAt mà không cần ép kiểu.
Từ TypeScript 5.5, compiler tự suy luận type predicate cho các arrow function đơn giản. Ví dụ orders.filter(o => o.status === "paid") cũng trả về PaidOrder[]. Việc suy luận này chỉ hoạt động khi biểu thức đủ đơn giản và quan hệ “true ⇔ thuộc kiểu” là chính xác. Với logic phức tạp hơn, bạn vẫn cần viết type guard tường minh.
3.3. Kiểm tra dữ liệu unknown
Ứng dụng thực tế nhất của type guard là kiểm tra dữ liệu ngoài, chẳng hạn JSON từ API:
interface Customer {
id: string;
name: string;
email: string;
}
function isCustomer(value: unknown): value is Customer {
return (
typeof value === "object" &&
value !== null &&
"id" in value && typeof value.id === "string" &&
"name" in value && typeof value.name === "string" &&
"email" in value && typeof value.email === "string"
);
}
const raw: unknown = JSON.parse('{"id":"C1","name":"Lan","email":"lan@example.com"}');
if (isCustomer(raw)) {
console.log(raw.email.toUpperCase());
}
LAN@EXAMPLE.COM
Từ TypeScript 4.9, toán tử in thu hẹp value thành object & Record<"id", unknown>. Vì vậy bạn viết được value.id mà không cần as any. Mỗi trường được kiểm tra cả sự tồn tại lẫn kiểu dữ liệu.
3.4. Bẫy: type guard nói dối
Compiler tin tuyệt đối vào type predicate. Nó không kiểm tra thân hàm có thực sự khớp với lời hứa hay không:
function isCustomerBroken(value: unknown): value is Customer {
return typeof value === "object" && value !== null; // quên kiểm tra các trường
}
const data: unknown = { id: 1 };
if (isCustomerBroken(data)) {
console.log(data.email.toUpperCase()); // compile OK, runtime nổ
}
TypeError: Cannot read properties of undefined (reading 'toUpperCase')
Có một bẫy tinh vi hơn nằm ở nhánh false:
function isLargeAmount(value: string | number): value is number {
return typeof value === "number" && value > 1000;
}
function format(value: string | number) {
if (isLargeAmount(value)) return `Lớn: ${value}`;
return value.toUpperCase(); // TS nghĩ value: string
}
format(5); // TypeError: value.toUpperCase is not a function
Hàm trả về false cho số 5. TypeScript lại suy ra rằng ở nhánh còn lại value không phải number. Điều này sai.
Lưu ý: Type predicate là một quan hệ hai chiều. Trả về
truephải đồng nghĩa với “thuộc kiểu T”. Trả vềfalsephải đồng nghĩa với “không thuộc kiểu T”. Đừng nhét thêm điều kiện nghiệp vụ (như> 1000) vào type guard. Hãy tách chúng thành hàmbooleanriêng.
4. Assertion function
4.1. asserts value is T
Type guard buộc bạn bọc code trong if. Có những lúc bạn muốn hành vi kiểu “nếu sai thì ném lỗi, còn lại cứ chạy tiếp”. Đó là việc của assertion function:
function assertIsCustomer(value: unknown): asserts value is Customer {
if (!isCustomer(value)) {
throw new TypeError("Dữ liệu không phải Customer hợp lệ");
}
}
function greet(payload: unknown): string {
assertIsCustomer(payload);
// từ dòng này trở đi, payload: Customer
return `Xin chào ${payload.name}`;
}
console.log(greet({ id: "C1", name: "Minh", email: "minh@example.com" }));
console.log(greet({ id: "C2" }));
Xin chào Minh
TypeError: Dữ liệu không phải Customer hợp lệ
Điểm khác biệt so với type guard:
- Kiểu trả về là
asserts value is Customer. Thân hàm khôngreturngiá trị nào, tức hàm trả vềvoid. - Hàm ném lỗi khi điều kiện sai. Nếu hàm trả về bình thường, TypeScript coi như điều kiện đúng.
- Kiểu được thu hẹp cho toàn bộ phần code phía sau lời gọi, không chỉ trong một khối
if.
4.2. asserts condition
Dạng thứ hai không gắn với kiểu cụ thể. Nó khẳng định một biểu thức là truthy, giống assert của Node.js:
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
function applyDiscount(order: Order, percent: number): number {
assert(order.status !== "cancelled", `Đơn ${order.id} đã huỷ`);
// order: PendingOrder | PaidOrder → có total
assert(percent > 0 && percent <= 50, "Giảm giá phải trong (0, 50]");
return order.total * (1 - percent / 100);
}
console.log(applyDiscount({ status: "pending", id: "A1", total: 200 }, 10));
180
Sau dòng assert(order.status !== "cancelled", ...), TypeScript thu hẹp order giống như bạn vừa viết if. Vì vậy order.total hợp lệ, dù CancelledOrder không có trường total.
4.3. Lỗi TS2775: thiếu type annotation tường minh
Đây là lỗi hay gặp nhất khi viết assertion function dưới dạng arrow function:
const assertPositive = (n: number): asserts n => {
if (n <= 0) throw new Error("Phải dương");
};
assertPositive(5);
error TS2775: Assertions require every name in the call target
to be declared with an explicit type annotation.
Compiler cần biết kiểu của assertPositive ngay tại điểm khai báo, không được dựa vào suy luận. Bạn có hai cách sửa. Cách thứ nhất là dùng function declaration. Cách thứ hai là gắn kiểu cho biến:
type AssertPositive = (n: number) => asserts n;
const assertPositive: AssertPositive = (n) => {
if (n <= 0) throw new Error("Phải dương");
};
Quy tắc này cũng áp dụng cho method gọi qua đối tượng, ví dụ validator.assertCustomer(x). Biến validator phải có kiểu khai báo tường minh, chẳng hạn const validator: CustomerValidator = new CustomerValidator().
Lưu ý: Assertion function không thể là
async. KiểuPromise<asserts x is T>không tồn tại. Nếu cần kiểm tra bất đồng bộ, hãyawaitđể lấy dữ liệu trước, rồi gọi assertion function đồng bộ trên kết quả.
5. Viết test cho type guard và assertion function
Đây là logic runtime, nên bạn cần test như mọi hàm khác. Ví dụ dưới dùng Vitest:
npm install -D vitest typescript
// customer.test.ts
import { describe, it, expect } from "vitest";
import { isCustomer, assertIsCustomer } from "./customer";
describe("isCustomer", () => {
it("trả về true với object hợp lệ", () => {
expect(isCustomer({ id: "C1", name: "Lan", email: "lan@example.com" })).toBe(true);
});
it.each([
null,
"C1",
{ id: "C1", name: "Lan" },
{ id: 1, name: "Lan", email: "lan@example.com" },
])("trả về false với %j", (input) => {
expect(isCustomer(input)).toBe(false);
});
});
describe("assertIsCustomer", () => {
it("ném TypeError khi dữ liệu sai", () => {
expect(() => assertIsCustomer({ id: "C1" })).toThrow(TypeError);
});
it("thu hẹp kiểu khi dữ liệu đúng", () => {
const payload: unknown = { id: "C1", name: "Lan", email: "lan@example.com" };
assertIsCustomer(payload);
expect(payload.email).toBe("lan@example.com"); // compile được nhờ narrowing
});
});
npx vitest run
✓ customer.test.ts (7 tests)
Test Files 1 passed (1)
Tests 7 passed (7)
Hãy chú ý các case âm tính: thiếu trường, sai kiểu, null, giá trị nguyên thuỷ. Phần lớn bug của type guard nằm ở những trường hợp nó trả về true khi lẽ ra phải là false, như ví dụ ở mục 3.4. Test cuối cùng còn đóng vai trò test ở compile time. Nếu ai đó đổi chữ ký hàm thành void, file test sẽ không biên dịch được.
6. Chọn type guard hay assertion function?
| Tình huống | Nên dùng |
|---|---|
| Dữ liệu sai là chuyện bình thường, cần rẽ nhánh xử lý | Type guard |
Lọc mảng, find, kết hợp với filter |
Type guard |
| Dữ liệu sai là lỗi lập trình hoặc vi phạm hợp đồng, nên dừng ngay | Assertion function |
Kiểm tra đầu hàm (precondition), tránh lồng if |
Assertion function |
Hai công cụ thường đi cùng nhau. Bạn viết type guard làm lõi, rồi bọc nó trong assertion function, như cách assertIsCustomer gọi isCustomer. Nhờ vậy logic kiểm tra chỉ nằm ở một chỗ.
Khi nào KHÔNG nên tự viết:
- Schema lớn hoặc lồng sâu. Viết tay
isCustomercho object có 30 trường và mảng lồng nhau rất dễ sót. Một thư viện validation như Zod hay Valibot sinh kiểu và kiểm tra từ cùng một schema, nên hai thứ không thể lệch nhau. - Thay thế cho
as. Type guard có thân hàm rỗng ruột (return true) không an toàn hơnvalue as Customer. Nó chỉ che giấu vấn đề kỹ hơn. - Discriminated union đơn giản. Nếu
if (order.status === "paid")đã đủ rõ ràng, đừng tách ra hàm chỉ để có hàm.
7. Kết luận
Type guard (value is T) cho phép bạn đóng gói logic kiểm tra kiểu vào một hàm mà vẫn giữ được narrowing ở nơi gọi. Nó đặc biệt hợp với if và filter. Assertion function (asserts value is T hoặc asserts condition) thu hẹp kiểu cho toàn bộ phần code phía sau bằng cách ném lỗi khi điều kiện sai. Nó phù hợp để kiểm tra precondition và dữ liệu đầu vào.
Cả hai đều là lời hứa mà compiler không kiểm chứng. Vì vậy hãy nhớ ba điều:
- Giữ quan hệ “true ⇔ thuộc kiểu”.
- Khai báo kiểu tường minh cho assertion function.
- Test kỹ các trường hợp âm tính.