TypeScript discriminated union type guard thay if-else

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.

Code editor TypeScript discriminated union pattern

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.

TypeScript exhaustive switch type guard narrowing

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

  1. Luôn dùng literal type cho discriminant ('success' không phải string).
  2. Đặt tên discriminant nhất quán: kind, type, status, tag.
  3. Dùng interface cho variant phức tạp, type alias cho variant đơn giản.
  4. Luôn có default: const _: never = x hoặc .exhaustive().
  5. Validate runtime với Zod/Valibot khi nhận dữ liệu external.
  6. 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:

Tôi là một lập trình viên IOS. Code chính là IOS nhưng thỉnnh thoảng vẫn đá sang Android hoặc web. Mặc dù không quá thông thạo nhưng tôi sẽ chia sẻ những kiến thức mà mình đã tìm hiểu, áp dụng qua.

Bài viết liên quan

Bun 1.1: JavaScript Runtime Mới Cho Full-Stack Development

Bun 1.1: JavaScript Runtime Mới Cho Full-Stack Development Bun là một JavaScript runtime mới nổi, được thiết kế từ đầu để thay thế Node.js trong nhiều trường hợp sử dụng….

Xem thêm

Flask 3.1: Micro Framework Web Python Hiện Đại Cho Dự Án Mới

Flask 3.1 là phiên bản mới nhất của framework web Python nhẹ nhất — và là một trong những dự án được tải nhiều nhất trên PyPI (trên 20 triệu…

Xem thêm
Minh họa lập trình Go 1.24 generic type aliases

Go 1.24: Generic Type Aliases, Tool Directives và Crypto Post-Quantum

Go 1.24 là bản phát hành mới nhất của ngôn ngữ lập trình Go, ra mắt tháng 2/2025 sáu tháng sau Go 1.23. Bản release này tập trung cải tiến…

Xem thêm
0 0 đánh giá
Article Rating
Theo dõi
Thông báo của
guest
0 Comments
Cũ nhất
Mới nhất Được bỏ phiếu nhiều nhất