Discriminated Unions
Discriminated Unions
Bài trước bạn đã học type guards để narrow type. Nhưng có một pattern mạnh hơn nhiều — nơi mà TypeScript tự động narrow type cho bạn mà không cần viết typeof hay instanceof.
Đó là discriminated union — và nó thay đổi cách bạn viết code xử lý nhiều loại data.
#Vấn đề: xử lý nhiều shape khác nhau
Giả sử bạn có API trả về kết quả — có thể thành công hoặc thất bại:
// ❌ Không rõ ràng — không biết field nào có khi nào
interface ApiResponse {
data: any;
error: any;
status: number;
}
function handleResponse(res: ApiResponse) {
// ❌ Phải tự kiểm tra — dễ quên, dễ sai
if (res.error) {
console.error(res.error);
} else {
console.log(res.data.name); // 💀 Runtime error nếu data là undefined
}
}Cách tiếp cận này có nhiều vấn đề:
- Cả
datavàerrorluôn tồn tại — bạn không biết field nào hợp lệ - Dễ quên kiểm tra một trường hợp
- Không có cách nào để TypeScript kiểm tra bạn đã xử lý hết chưa
#Discriminated union là gì?
Một discriminated union (còn gọi là tagged union) gồm:
- Mỗi type trong union có một literal type field chung (gọi là discriminant/tag)
- Giá trị của field đó khác nhau ở mỗi type
interface SuccessResponse {
kind: "success"; // ← literal type, không phải string
data: { name: string; age: number };
}
interface ErrorResponse {
kind: "error"; // ← literal type khác
message: string;
code: number;
}
type ApiResponse = SuccessResponse | ErrorResponse;Field kind là discriminant. TypeScript nhìn vào giá trị của kind để narrow toàn bộ object.
#Exhaustive switch với never
Đây là sức mạnh thực sự — khi bạn dùng switch trên discriminant, TypeScript kiểm tra bạn đã xử lý tất cả các trường hợp chưa:
function handleResponse(res: ApiResponse): string {
switch (res.kind) {
case "success":
return `Hello ${res.data.name}`; // ✅ res là SuccessResponse
case "error":
return `Error ${res.code}: ${res.message}`; // ✅ res là ErrorResponse
default:
// ✅ Nếu bạn thêm kind mới vào union mà quên xử lý,
// TypeScript sẽ báo lỗi ở đây vì never không assign được cho bất kỳ type nào
const _exhaustive: never = res;
return _exhaustive;
}
}Giờ nếu ai đó thêm PendingResponse với kind: "pending" vào union mà quên update handleResponse, TypeScript sẽ báo lỗi ngay tại dòng const _exhaustive: never = res;.
Đây gọi là exhaustiveness check — compiler đảm bảo bạn không miss bất kỳ trường hợp nào.
#Pattern thực tế: State machine
Discriminated union cực kỳ phù hợp cho async state — loading, success, error:
type AsyncState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function renderUser(state: AsyncState<{ name: string }>) {
switch (state.status) {
case "idle":
return "Chưa bắt đầu";
case "loading":
return "Đang tải...";
case "success":
return `Xin chào ${state.data.name}`; // ✅ data chắc chắn tồn tại
case "error":
return `Lỗi: ${state.error.message}`; // ✅ error chắc chắn tồn tại
default:
const _: never = state;
return _;
}
}Tại sao dùng status thay vì kind? Không có quy luật nào cả. kind, type, status, tag — tất cả đều được. Chọn tên nào mô tả đúng ngữ nghĩa nhất.
Với React:
type UserState =
| { status: "loading" }
| { status: "error"; error: Error }
| { status: "success"; user: { name: string; email: string } };
function UserProfile({ state }: { state: UserState }) {
switch (state.status) {
case "loading":
return <Spinner />;
case "error":
return <ErrorMessage error={state.error} />;
case "success":
return <div>{state.user.name}</div>; // ✅ state.user tồn tại
default:
const _: never = state;
return _;
}
}#Pattern thực tế: Redux-style actions
Đây là một trong những use case phổ biến nhất:
type Action =
| { type: "INCREMENT"; payload: number }
| { type: "DECREMENT"; payload: number }
| { type: "RESET" }
| { type: "SET_NAME"; payload: string };
interface State {
count: number;
name: string;
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case "INCREMENT":
return { ...state, count: state.count + action.payload }; // ✅ payload là number
case "DECREMENT":
return { ...state, count: state.count - action.payload }; // ✅ payload là number
case "RESET":
return { ...state, count: 0 }; // ✅ Không có payload
case "SET_NAME":
return { ...state, name: action.payload }; // ✅ payload là string
default:
const _: never = action;
return _;
}
}Nhìn kỹ: ở mỗi case, TypeScript narrow cả type của action — action.payload tự động có đúng type. Không cần casting, không cần type guard thủ công.
#Kết hợp với user-defined type guard
Discriminated union có thể kết hợp với type guard:
interface Cat {
type: "cat";
meow(): void;
}
interface Dog {
type: "dog";
bark(): void;
}
type Pet = Cat | Dog;
function isCat(pet: Pet): pet is Cat {
return pet.type === "cat";
}
// Nhưng thực ra bạn không cần type guard ở đây — switch đã đủ:
function speak(pet: Pet) {
switch (pet.type) {
case "cat":
pet.meow(); // ✅
break;
case "dog":
pet.bark(); // ✅
break;
default:
const _: never = pet;
return _;
}
}Với discriminated union, bạn hiếm khi cần user-defined type guard. Nhưng nếu code ở nhiều nơi khác nhau cần check, type guard vẫn hữu ích.
#Nested discriminated unions
Thực tế thường gặp union lồng nhau:
type TextContent = { type: "text"; value: string };
type ImageContent = { type: "image"; url: string; alt: string };
type CardContent = TextContent | ImageContent;
type Card = {
type: "card";
title: string;
content: CardContent;
};
type Section = {
type: "section";
heading: string;
items: (Card | TextContent)[];
};
type Block = Card | Section | TextContent | ImageContent;
function renderBlock(block: Block): string {
switch (block.type) {
case "card":
// block là Card — tiếp tục switch trên content
switch (block.content.type) {
case "text":
return `[Card: ${block.title}] ${block.content.value}`;
case "image":
return `[Card: ${block.title}] <img src="${block.content.url}" alt="${block.content.alt}">`;
default:
const _: never = block.content;
return _;
}
case "section":
return block.items.map((item) => renderBlock(item)).join("\n");
case "text":
return block.value;
case "image":
return `<img src="${block.url}" alt="${block.alt}">`;
default:
const _: never = block;
return _;
}
}#Anti-pattern: Quên default/never case
Sai lầm phổ biến nhất khi dùng discriminated union:
// ❌ Không có exhaustiveness check
function handleAction(action: Action) {
switch (action.type) {
case "INCREMENT":
// ...
break;
case "DECREMENT":
// ...
break;
// Quên RESET và SET_NAME — không có lỗi compile, nhưng runtime sẽ miss
}
}
// ❌ Dùng default để "bỏ qua" thay vì never
function handleAction2(action: Action) {
switch (action.type) {
case "INCREMENT":
// ...
break;
default:
break; // Che giấu lỗi — nếu thêm action mới, bạn sẽ không biết
}
}
// ✅ Luôn có default với never
function handleAction3(action: Action) {
switch (action.type) {
case "INCREMENT":
// ...
break;
case "DECREMENT":
// ...
break;
case "RESET":
// ...
break;
case "SET_NAME":
// ...
break;
default:
const _: never = action; // Compile error nếu miss case
return _;
}
}Một anti-pattern khác: dùng object thay vì discriminated union cho nhiều loại data liên quan:
// ❌ Anti-pattern: một object chứa tất cả
interface Result {
type: "success" | "error";
data?: any;
error?: string;
}
// ✅ Discriminated union rõ ràng hơn nhiều
type Result =
| { type: "success"; data: any }
| { type: "error"; error: string };Với object chung, bạn vẫn phải kiểm tra data và error có tồn tại không. Với discriminated union, TypeScript đã biết field nào có mặt.
#Tổng kết
- Discriminated union = union types có một literal type field chung (discriminant)
- TypeScript tự động narrow khi bạn switch/check trên discriminant
- Exhaustiveness check với
neverđảm bảo bạn xử lý tất cả trường hợp - Pattern thực tế: state machine (idle/loading/success/error), Redux actions, API responses
- Luôn có
default: const _: never = xtrong switch — đó là lưới an toàn - Anti-pattern: quên exhaustiveness check, dùng object optional fields thay vì union rõ ràng
- Khi nào dùng: khi bạn có nhiều "hình thái" khác nhau của cùng một concept
Bài tiếp theo: Conditional Types — cách tạo type dựa trên điều kiện, nâng cao hơn discriminated union rất nhiều.