Series Total TypeScript Thực chiến #6: Discriminated Unions & Exhaustiveness Check – Trấn yểm luồng dữ liệu
1. Nỗi đau khi Consumer nhận Message
Trong kiến trúc Event-Driven, một Service (Consumer) thường phải hứng rất nhiều loại Event khác nhau từ một Queue (Kafka/RabbitMQ).
Giả sử hệ thống trạm thu phí AFC của chúng ta có 3 loại Event đẩy về Server:
type GateOpenedEvent = {
stationId: string;
gateId: string;
timestamp: number;
};
type TicketInvalidEvent = {
stationId: string;
cardUid: string;
errorCode: number;
};
type HardwareFaultEvent = {
stationId: string;
deviceId: string;
errorLog: string;
};
// Gom tất cả vào một Union Type
type StationEvent = GateOpenedEvent | TicketInvalidEvent | HardwareFaultEvent;
Khi bạn viết hàm Consumer xử lý StationEvent, bạn sẽ gặp một rắc rối lớn. TypeScript sẽ báo lỗi nếu bạn cố truy cập event.cardUid, vì thuộc tính này KHÔNG tồn tại trong GateOpenedEvent hay HardwareFaultEvent.
❌ Sai lầm phổ biến của người mới: Dùng bừa bãi toán tử as (Ép kiểu) hoặc ép về any, phá nát hoàn toàn sự an toàn của TypeScript.
2. Thuốc giải: Thêm "Mác phân loại" (Discriminator)
Kỹ thuật Discriminated Unions yêu cầu bạn phải chèn vào mỗi Object một thuộc tính chung, thường đóng vai trò như một hằng số (literal string) để làm mác nhận diện. Chúng ta thường dùng tên key là type, kind, hoặc eventType.
Hãy "độ" lại các Type trên:
type GateOpenedEvent = {
eventType: 'GATE_OPENED'; // Mác nhận diện
stationId: string;
gateId: string;
};
type TicketInvalidEvent = {
eventType: 'TICKET_INVALID';
stationId: string;
cardUid: string;
errorCode: number;
};
type HardwareFaultEvent = {
eventType: 'HARDWARE_FAULT';
stationId: string;
deviceId: string;
errorLog: string;
};
type StationEvent = GateOpenedEvent | TicketInvalidEvent | HardwareFaultEvent;
3. Phép màu Type Narrowing (Thu hẹp kiểu)
Bây giờ, khi bạn dùng lệnh if hoặc switch để kiểm tra thuộc tính eventType, TypeScript sẽ tự động kích hoạt tính năng Type Narrowing. Nó đủ thông minh để tự động loại trừ và "thu hẹp" cái Union khổng lồ kia về đúng Type chính xác bên trong mỗi nhánh logic.
function processMessageQueue(event: StationEvent) {
// Tại dòng này, event vẫn là StationEvent khổng lồ
switch (event.eventType) {
case 'GATE_OPENED':
// 🔥 Phép màu: Rê chuột vào biến event ở dòng này,
// TS hiểu nó CHẮC CHẮN là GateOpenedEvent. Bấm dấu chấm (.) sẽ ra gateId.
console.log(`Cổng ${event.gateId} đã mở`);
break;
case 'TICKET_INVALID':
// Ở đây TS hiểu nó là TicketInvalidEvent
console.log(`Thẻ ${event.cardUid} bị lỗi mã ${event.errorCode}`);
break;
case 'HARDWARE_FAULT':
// Ở đây TS hiểu nó là HardwareFaultEvent
console.log(`Lỗi thiết bị ${event.deviceId}: ${event.errorLog}`);
break;
}
}
Bạn không cần dùng bất cứ một chữ as nào. Code chạy mượt mà, gợi ý chuẩn xác 100%.
4. Đỉnh cao của Senior: Exhaustiveness Check với never
Luồng xử lý Message ở phần 3 đã hoàn hảo chưa? Chưa! Sẽ thế nào nếu 3 tháng sau, team thiết bị bổ sung thêm một Event mới là FirmwareUpdatedEvent vào StationEvent?
type FirmwareUpdatedEvent = {
eventType: 'FIRMWARE_UPDATED';
version: string;
}
type StationEvent = GateOpenedEvent | TicketInvalidEvent | HardwareFaultEvent | FirmwareUpdatedEvent;
Hàm processMessageQueue cũ của bạn sẽ bỏ sót hoàn toàn Event mới này vì không có nhánh case 'FIRMWARE_UPDATED'. TypeScript mặc định sẽ bỏ qua và không hề báo lỗi, dẫn đến việc Message bị "nuốt" mất mà không được xử lý.
Đây là lúc kỹ thuật trấn yểm của Matt Pocock phát huy sức mạnh. Hãy bổ sung nhánh default vào lệnh switch và dùng Type never:
function processMessageQueue(event: StationEvent) {
switch (event.eventType) {
case 'GATE_OPENED':
// ...
break;
case 'TICKET_INVALID':
// ...
break;
case 'HARDWARE_FAULT':
// ...
break;
default:
// 🔥 TRẤN YỂM: Gán event vào một biến có kiểu là 'never'
const _exhaustiveCheck: never = event;
throw new Error(`Unhandled event type: ${_exhaustiveCheck}`);
}
}
Điều gì vừa xảy ra?
- Bản chất của Type never là "không có giá trị nào có thể gán vào được".
- Khi bạn bắt đủ tất cả các case, TypeScript hiểu rằng biến event lọt xuống nhánh default sẽ không còn mang Type nào nữa (đã bị vét cạn). Do đó, phép gán never = event là hợp lệ.
- NHƯNG, nếu bạn thêm FirmwareUpdatedEvent mà quên viết case, TypeScript sẽ nhận ra biến event lọt xuống default vẫn còn mang type FirmwareUpdatedEvent. Nó lập tức gạch chân đỏ chóe tại dòng const _exhaustiveCheck: "Type 'FirmwareUpdatedEvent' is not assignable to type 'never'".
- Bằng một thủ thuật nhỏ xíu, bạn đã buộc TypeScript phải tự động làm QA/Tester, ép bạn (hoặc người đồng nghiệp mới) phải xử lý triệt để mọi Event mới sinh ra trong tương lai, không cho phép dự án được compile nếu thiếu sót.
Bằng việc kết hợp hệ thống Type tự động nội suy (Từ Bài 1 đến Bài 5) và kỹ thuật bắt lỗi vét cạn (Bài 6), dự án Node.js của bạn giờ đây đã khoác lên mình một bộ giáp chống đạn. Lỗi Runtime do sai cấu trúc dữ liệu gần như bị triệt tiêu hoàn toàn ngay từ lúc bạn đang gõ code.
All rights reserved