Hồi trước mình refactor một helper generic trong thư viện nội bộ: gom ba hàm riêng thành một hàm request<T>() cho gọn. Chạy pnpm test — 180 test xanh. tsc --noEmit — không một lỗi. Merge.

Ba ngày sau, service đặt hàng báo lỗi lạ: data.items là undefined dù API luôn trả về mảng. Mở ra coi thì thủ phạm nằm ở cái refactor kia — nhánh lỗi của request<T>() bị suy luận thành any, và từ đó lan ra khắp nơi mà không ai thấy. Toàn bộ test đều xanh, vì test không kiểm tra type.

Test runtime không nhìn thấy type

expect(result).toEqual({ ok: true, data: [] }) chỉ so giá trị tại thời điểm chạy. Nếu hàm trả về any, hay union bị nới thành T | null | undefined, test vẫn xanh y nguyên — nó không quan tâm biến đó được khai báo kiểu gì.

Type chỉ tồn tại lúc compile, nên muốn test type thì phải nhờ chính compiler. tsc --noEmit trong CI thì có sẵn rồi, nhưng nó chỉ bắt lỗi đang có trong code. Nó không bắt được kiểu bị nới rộng im lặng — ví dụ hàm thư viện của bạn tự dưng trả về any, hay một generic mất luôn tính phân biệt của union. Đó là việc của type test.

expectTypeOf — test kiểu như test hàm

Vitest cho bạn viết assertion trên type, chạy chung với test thường. Đặt file tên *.test-d.ts, Vitest tự hiểu đây là type test:

// src/api/request.test-d.ts
import { expectTypeOf, it } from "vitest";
import { request } from "./request";

type ApiResult<T> = { ok: true; data: T } | { ok: false; error: string };

it("request giữ đúng kiểu trả về", () => {
  const p = request<{ id: string }>("/users/1");

  expectTypeOf(p).toEqualTypeOf<Promise<ApiResult<{ id: string }>>>();

  // nhánh lỗi phải là string, không được thành any
  expectTypeOf(p).resolves.toHaveProperty("error").toEqualTypeOf<string>();
});

Nếu request lỡ trả về any, toEqualTypeOf đỏ ngay trong CI. Chỗ này mình thấy hữu dụng nhất: nó khoá được hợp đồng kiểu của public API, thứ mà tsc một mình không giữ nổi khi bạn refactor.

Muốn rộng hơn một chút thì dùng toExtend — chỉ cần assignable, không cần trùng khít. Ví dụ hàm mount nhận object có name nhưng cho phép field khác đi kèm:

expectTypeOf(mount).parameter(0).toExtend<{ name: string }>();

Assertion dạng "phải lỗi"

Test type không chỉ để khẳng định kiểu đúng, mà còn để chắc rằng kiểu sai bị chặn. Cách quen thuộc là @ts-expect-error:

type UserId = string & { readonly __brand: "UserId" };
declare function getUser(id: UserId): Promise<{ name: string }>;

// @ts-expect-error string thường không phải UserId
getUser("u_123");

Một bẫy mình từng dính: @ts-expect-error chỉ cần có lỗi bất kỳ là xanh, nên nếu bạn gõ sai tên biến thì test vẫn xanh và bạn tưởng brand type hoạt động. Cách chặn: để file type test lọt vào cả test.include, khi đó Vitest chạy file thật và lỗi gõ sai sẽ nổ ReferenceError.

Bật trong CI

// vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    typecheck: {
      enabled: true,
      include: ["src/**/*.test-d.ts"],
      ignoreSourceErrors: false, // lỗi type trong src cũng làm đỏ CI
    },
  },
});
{
  "scripts": {
    "test": "vitest --run",
    "test:types": "vitest --typecheck --run"
  }
}

Bên dưới, Vitest gọi tsc --noEmit (hoặc vue-tsc) rồi đọc kết quả, nên bạn gỡ được script typecheck riêng trong pipeline. Type test chậm hơn test thường kha khá vì phải chạy cả compiler, nên mình tách thành job riêng cho chạy song song; muốn chỉ chạy type thì thêm --typecheck.only.

Một chi tiết đáng lưu: trước Vitest 2.1, typecheck.include ghi đè include, thành ra test runtime của bạn không hề chạy, chỉ được type-check. Từ 2.1 hai cái overlap được và Vitest báo thành hai entry riêng — nhưng nếu bạn còn dùng bản cũ thì đừng đặt typecheck.include quá rộng.

Vài chỗ khác mình vấp

  • So sánh bằng typearg, đừng bằng object cụ thể: toEqualTypeOf<{ a: string }>() cho thông báo lỗi đọc được, còn toEqualTypeOf({ a: "" }) chỉ trả về một Mismatch mơ hồ.
  • File type test không được chạy, chỉ được phân tích static. Nên đặt tên test động kiểu test.each vô nghĩa — tên sẽ hiện nguyên văn string.
  • Đừng viết type test cho mọi hàm. Chỉ đáng cho public API: signature thư viện, type suy ra từ schema zod, builder/factory, và các discriminated union quan trọng.
  • Xoá một khai báo @ts-expect-error cũ khi kiểu đã sửa xong — để lại là CI đỏ với thông báo "unused directive", mất thời gian dò.

Kết

tsc --noEmit là camera an ninh: chỉ báo cái đang xảy ra trong nhà. Type test là lưới riêng cho chuyện kiểu bị nới rộng lặng lẽ — chậm hơn, nhưng nó bắt được loại bug mà test runtime không bao giờ chạm tới. Với thư viện nội bộ dùng chung nhiều service, mình coi vài chục dòng type test rẻ hơn nhiều so với ba ngày debug undefined như lần đó.

Chỗ bạn làm đã có type test chưa, hay vẫn đang tin vào tsc --noEmit xanh là đủ?

📋 Phụ lục thuật ngữ

Thuật ngữ Giải thích ngắn
Type test Test khẳng định về kiểu dữ liệu, chạy bằng compiler thay vì lúc runtime
expectTypeOf API của Vitest để viết assertion trên type, ví dụ toEqualTypeOf, toExtend
assertType Cách viết gọn hơn: assertType<number>(answer), sai kiểu là lỗi compile
@ts-expect-error Chú thích báo compiler rằng dòng dưới phải có lỗi type; không có lỗi thì báo lỗi
Branded type Gắn nhãn vào type cơ sở (ví dụ string & { __brand: "UserId" }) để chặn trộn lẫn
Discriminated union Union có field phân biệt để TypeScript thu hẹp kiểu theo từng nhánh
toEqualTypeOf So sánh hai type trùng khít, kể cả optional và readonly
toExtend Chỉ yêu cầu assignable — type thực rộng hơn type mong đợi vẫn pass
Suy luận kiểu (inference) TypeScript tự đoán type từ giá trị; chỗ hay sinh ra any ngoài ý muốn
typecheck.include Pattern khai báo file nào được coi là type test, mặc định *.test-d.ts

Xem các bài viết khác