
TypeScript discriminated union là gì? Giải quyết if-else lồng nhau
TypeScript discriminated union (còn gọi là tagged union hoặc sum type) là pattern mô hình hóa tập hợp hữu hạn các trạng thái riêng biệt, mỗi trạng thái mang một discriminant property (thường là field kind hoặc type) cho phép TypeScript thu hẹp kiểu (narrowing) chính xác tại compile-time. Thay vì viết chuỗi if (x.type === 'A') ... else if (x.type === 'B') ... lồng nhau dễ sai sót, discriminated union biến logic phân nhánh thành exhaustive switch — compiler báo lỗi nếu quên xử lý case nào.
Pattern này đến từ functional programming (ML, Haskell, F#, Rust enum) và hiện là best practice cho state management, API response handling, form validation, event processing trong codebase TypeScript hiện đại.

Cú pháp cơ bản: Định nghĩa discriminated union
Yếu tố then chốt: tất cả các member trong union phải chia sẻ một field literal type giống nhau.
// Định nghĩa các trạng thái request API
type ApiResponse =
| { kind: 'loading' }
| { kind: 'success'; data: User[] }
| { kind: 'error'; message: string; code: number }
| { kind: 'empty' };
// Hoặc dùng interface cho từng variant (khuyến nghị cho type phức tạp)
interface LoadingState { kind: 'loading'; }
interface SuccessState { kind: 'success'; data: User[]; }
interface ErrorState { kind: 'error'; message: string; code: number; }
interface EmptyState { kind: 'empty'; }
type ApiState = LoadingState | SuccessState | ErrorState | EmptyState;
Field kind là discriminant — TypeScript dùng nó để phân biệt variant tại runtime.
Type narrowing với switch exhaustive
Khi switch trên discriminant, TypeScript tự động narrow type trong từng case:
function render(state: ApiState): React.ReactNode {
switch (state.kind) {
case 'loading':
return ; // state: LoadingState
case 'success':
return ; // state: SuccessState, có .data
case 'error':
return ; // state: ErrorState
case 'empty':
return ; // state: EmptyState
default:
// Exhaustiveness check: nếu thêm variant mới mà quên case này,
// TypeScript báo lỗi: "Type 'never' is not assignable to type 'ApiState'"
const _exhaustive: never = state;
return _exhaustive;
}
}
Dòng const _exhaustive: never = state đảm bảo exhaustiveness — nếu union mở rộng thêm variant mà code không cập nhật, build fail.
Ví dụ thực tế: Xử lý payment status
Thay vì if (status === 'pending') ... else if (status === 'paid') ... rải rác khắp codebase:
type PaymentStatus =
| { status: 'pending'; createdAt: Date }
| { status: 'processing'; gatewayTxnId: string; startedAt: Date }
| { status: 'paid'; paidAt: Date; receiptUrl: string }
| { status: 'failed'; reason: string; retryCount: number }
| { status: 'refunded'; refundedAt: Date; amount: number };
function getActionButton(status: PaymentStatus): React.ReactNode {
switch (status.status) {
case 'pending':
return ;
case 'processing':
return ;
case 'paid':
return
Xem hóa đơn
;
case 'failed':
return status.retryCount < 3
?
: Thanh toán thất bại: {status.reason};
case 'refunded':
return Đã hoàn tiền {status.amount}đ;
}
}
Lợi ích: thêm status 'disputed' → compiler báo lỗi tại getActionButton và mọi nơi switch trên PaymentStatus.

Discriminated union cho event handling (Redux-style)
type AppEvent =
| { type: 'USER_LOGIN'; payload: { user: User; token: string } }
| { type: 'USER_LOGOUT' }
| { type: 'DATA_FETCH_START'; resource: string }
| { type: 'DATA_FETCH_SUCCESS'; resource: string; data: unknown }
| { type: 'DATA_FETCH_ERROR'; resource: string; error: Error }
| { type: 'UI_TOGGLE_SIDEBAR' };
function reducer(state: AppState, event: AppEvent): AppState {
switch (event.type) {
case 'USER_LOGIN':
return { ...state, user: event.payload.user, token: event.payload.token };
case 'USER_LOGOUT':
return { ...initialState };
case 'DATA_FETCH_START':
return { ...state, loading: { ...state.loading, [event.resource]: true } };
case 'DATA_FETCH_SUCCESS':
return {
...state,
loading: { ...state.loading, [event.resource]: false },
data: { ...state.data, [event.resource]: event.data },
};
case 'DATA_FETCH_ERROR':
return {
...state,
loading: { ...state.loading, [event.resource]: false },
errors: { ...state.errors, [event.resource]: event.error.message },
};
case 'UI_TOGGLE_SIDEBAR':
return { ...state, ui: { ...state.ui, sidebarOpen: !state.ui.sidebarOpen } };
}
}
Mọi event đều có field type literal — TypeScript narrow payload tự động.
Kết hợp với Zod cho runtime validation
Discriminated union tại compile-time cần runtime validation cho dữ liệu từ API/external:
import { z } from 'zod';
const PaymentStatusSchema = z.discriminatedUnion('status', [
z.object({ status: z.literal('pending'), createdAt: z.string().datetime() }),
z.object({ status: z.literal('processing'), gatewayTxnId: z.string(), startedAt: z.string().datetime() }),
z.object({ status: z.literal('paid'), paidAt: z.string().datetime(), receiptUrl: z.string().url() }),
z.object({ status: z.literal('failed'), reason: z.string(), retryCount: z.number().int().min(0) }),
z.object({ status: z.literal('refunded'), refundedAt: z.string().datetime(), amount: z.number().positive() }),
]);
// Parse an toàn
const result = PaymentStatusSchema.safeParse(apiResponse);
if (result.success) {
// result.data là PaymentStatus đã narrow
renderPaymentUI(result.data);
}
Zod discriminatedUnion validate discriminant trước, sau đó validate schema của variant tương ứng — hiệu quả và type-safe.
Pattern matching với ts-pattern (thư viện nhẹ)
Nếu muốn syntax pattern matching mạnh hơn switch:
import { match } from 'ts-pattern';
const message = match(state)
.with({ kind: 'loading' }, () => 'Đang tải...')
.with({ kind: 'success' }, (s) => `Tải thành công ${s.data.length} user`)
.with({ kind: 'error' }, (s) => `Lỗi ${s.code}: ${s.message}`)
.with({ kind: 'empty' }, () => 'Không có dữ liệu')
.exhaustive(); // TypeScript đảm bảo exhaustive
ts-pattern (3kB gzipped) cho phép destructuring trong pattern, guards, và exhaustive checking tự động.
Khi nào KHÔNG nên dùng discriminated union
- Trạng thái không giới hạn: Danh sách user, log entries — dùng array/map.
- Variant chỉ khác 1-2 field optional: Dùng single interface với optional fields đơn giản hơn.
- Cần extend runtime behavior: Dùng class hierarchy + polymorphism (OOP) phù hợp hơn.
Best practices tóm gọn
- Luôn dùng literal type cho discriminant (
'success'không phảistring). - Đặt tên discriminant nhất quán:
kind,type,status,tag. - Dùng interface cho variant phức tạp, type alias cho variant đơn giản.
- Luôn có
default: const _: never = xhoặc.exhaustive(). - Validate runtime với Zod/Valibot khi nhận dữ liệu external.
- Export type union từ single source of truth (file types.ts), không duplicate.
Kết luận
TypeScript discriminated union biến logic phân nhánh phức tạp thành code type-safe, self-documenting, và maintainable. Compiler trở thành safety net: thêm variant mới → build fail ngay tại chỗ chưa handle. Pattern này là nền tảng của state management hiện đại (Redux Toolkit, XState, TanStack Query) và nên thành thói quen trong mọi codebase TypeScript quy mô trung bình trở lên.
Tham khảo thêm:
- TypeScript Handbook – Narrowing: typescriptlang.org/docs/handbook/2/narrowing.html
- ts-pattern docs: github.com/gvergnaud/ts-pattern
- Zod discriminatedUnion: zod.dev
- Effect-TS pattern matching: effect.website
