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 PaidOrder nghĩa là: nếu hàm trả về true thì order có kiểu PaidOrder.
  • Lúc runtime, hàm vẫn chỉ trả về true/false. Phần is chỉ tồn tại ở compile time.
  • Ở nhánh false, TypeScript loại PaidOrder khỏi union. order trở thành PendingOrder | 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ề true phải đồng nghĩa với “thuộc kiểu T”. Trả về false phả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àm boolean riê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ông return giá 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ểu Promise<asserts x is T> không tồn tại. Nếu cần kiểm tra bất đồng bộ, hãy await để 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 isCustomer cho 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ơn value 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.
typescripttype guardassertion functionnarrowingtype safety