Đợt rồi mình sửa một bug mất gần cả buổi: API trả 500 cho một số request, nhưng chỉ khi người dùng vừa mở trang vừa bấm refresh liền. Gốc rễ nằm ở chỗ state của component có hai field loading và error, và có lúc cả hai cùng true.
Nghe quen không? Đó là triệu chứng của một bệnh nặng hơn: state đang được biểu diễn bằng một túi field rời rạc, thay vì bằng một tập trạng thái hợp lệ. Khi mọi tổ hợp đều gõ ra được, tổ hợp sai cũng gõ ra được — rồi tới một ngày production sẽ gõ nó giùm bạn.
Khi state là một túi field tuỳ chọn
Kiểu khai báo này chắc bạn gặp hoài:
type FetchState<T> = {
loading: boolean;
data?: T;
error?: Error;
};
Nghe hợp lý. Nhưng đếm thử số tổ hợp: loading có 2 giá trị, data có/không, error có/không — 8 tổ hợp. Trong đó chỉ 3 tổ hợp có nghĩa. Năm tổ hợp còn lại là đất sống cho bug:
function render(state: FetchState<User[]>): string {
if (state.loading) return "Đang tải...";
if (state.error) return `Lỗi: ${state.error.message}`;
return state.data!.map((u) => u.name).join(", "); // dấu ! là niềm tin, không phải bằng chứng
}
Dấu ! ở dòng cuối chính là chỗ mình bị trả giá. Nó bảo compiler "tin tao đi, data có mà". Nhưng trạng thái { loading: false, error: undefined, data: undefined } hoàn toàn hợp lệ với type, và nó nổ đúng lúc không ai ngờ.
Đổi sang union: mỗi trạng thái mang đúng dữ liệu của nó
Thay vì một object nhiều field, để union mô tả từng trạng thái:
type FetchState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
Từ 8 tổ hợp xuống còn 4, và mỗi nhánh chỉ có field nó cần. Nhánh success bắt buộc phải có data, nhánh error bắt buộc phải có error. Trạng thái "không có gì mà cũng không loading" giờ không gõ ra được nữa — đó gọi là biến trạng thái không hợp lệ thành không thể biểu diễn.
Exhaustive check: compiler canh cửa giùm bạn
Field chung status gọi là discriminant. Nhờ nó, TypeScript tự thu hẹp type trong switch:
function assertNever(value: never): never {
throw new Error(`Trạng thái chưa xử lý: ${JSON.stringify(value)}`);
}
function render(state: FetchState<User[]>): string {
switch (state.status) {
case "idle":
return "";
case "loading":
return "Đang tải...";
case "success":
return state.data.map((u) => u.name).join(", "); // data chắc chắn có, không cần !
case "error":
return `Lỗi: ${state.error.message}`;
default:
return assertNever(state);
}
}
Giá trị thật của assertNever không nằm ở runtime mà ở lúc build. Hôm sau bạn thêm nhánh { status: "refreshing" } vào union, default không còn là never, build đỏ ngay tại dòng đó kèm cảnh báo Argument of type '{ status: "refreshing" }' is not assignable to parameter of type 'never'. Compiler biến thành checklist: thêm trạng thái mới thì buộc phải xử lý hết chỗ dùng nó, không thể quên trong im lặng.
Ví dụ đời thực: trạng thái đơn hàng
Union không chỉ để render UI. Mình áp cho đơn hàng, nơi bug "gửi hoá đơn hai lần" từng làm kế toán hỏi thăm:
type Order =
| { status: "pending" }
| { status: "paid"; paidAt: Date; transactionId: string }
| { status: "refunded"; paidAt: Date; refundedAt: Date };
type PaidOrder = Extract<Order, { status: "paid" }>;
function sendInvoice(order: PaidOrder): void {
console.log(`Hoá đơn cho giao dịch ${order.transactionId}`);
}
function markPaid(order: Extract<Order, { status: "pending" }>, transactionId: string): PaidOrder {
return { status: "paid", paidAt: new Date(), transactionId };
}
Extract<Order, { status: "paid" }> lấy đúng nhánh cần dùng. Gọi sendInvoice(orderChuaThanhToan) là lỗi compile ngay, không đợi tới lúc chạy. Và markPaid chỉ nhận đơn pending, nên không ai lỡ tay đánh dấu thanh toán cho đơn đã hoàn tiền. Cùng tinh thần với Branded Types — mình có viết riêng một bài về việc chặn bug ngay lúc compile thay vì lúc 3 giờ sáng.
Vài chỗ dễ làm hỏng
Đừng nửa vời. Nhét paidAt?: Date vào nhánh pending là trả hết công sức về số 0 — union mất luôn giá trị, vì field tuỳ chọn lại quay về.
strictNullChecks là điều kiện bắt buộc. Không bật thì error: undefined vẫn lọt qua, và ! vẫn "đúng". Cứ để strict: true trong tsconfig.json, mọi thứ ở trên mới đứng vững.
Và nhớ: type không kiểm tra runtime. Dữ liệu từ API ngoài hay cột JSON trong DB vẫn phải validate ở biên — Zod có z.discriminatedUnion làm đúng việc này — rồi mới giao cho phần còn lại của app tin tưởng.
Khi nào KHÔNG nên dùng
Union phù hợp khi trạng thái là tập hữu hạn, rời nhau. Không phù hợp khi luồng chuyển phức tạp: retry, timeout, cancel chồng nhau, hoặc nhiều bước có điều kiện. Lúc đó cần state machine thật (XState) hoặc một bảng transition tường minh; union chỉ mô tả được các nút, không mô tả được luật đi lại giữa chúng.
Còn nếu union phình quá 10 nhánh, mỗi nhánh 6-7 field thì cũng nên dừng lại. Đọc mệt, review khó, mà thường là dấu hiệu trạng thái nên nằm ở server chứ không phải client.
Kết
Chuyển { loading, data?, error? } sang union tốn của mình chừng hai chục phút, và xoá luôn một nhóm bug không còn cơ hội xuất hiện. Cái hay nhất là nó không đòi thư viện, không đòi build step mới — chỉ là cách bạn khai báo type.
Bạn đang có field ? nào mà bản thân nó đã ngầm nói "trạng thái này không được phép tồn tại"? Thử đếm số tổ hợp của nó xem, biết đâu ra một union đẹp.
📋 Phụ lục thuật ngữ
| Thuật ngữ | Giải thích ngắn |
|---|---|
| Discriminated union | Union các object có chung một field phân biệt (discriminant), ví dụ status |
| Discriminant | Field dùng để compiler biết đang ở nhánh nào của union |
| Narrowing | Thu hẹp type trong từng nhánh if/switch dựa trên discriminant |
| Exhaustive check | Kiểm tra đã xử lý hết mọi nhánh của union, thường bằng hàm nhận never |
| never | Type không có giá trị nào; dùng làm chốt chặn khi mọi nhánh đã xử lý |
| Extract<T, U> | Utility type lấy ra các nhánh của T khớp với U |
| strictNullChecks | Cờ tsconfig tách undefined/null khỏi mọi type, bắt buộc cho pattern này |
| Illegal states unrepresentable | Nguyên tắc thiết kế: làm cho trạng thái sai không gõ ra được trong type |
| State machine | Mô hình trạng thái + luật chuyển, hợp cho luồng phức tạp nhiều bước |
