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

SQLite FTS5 Full-Text Search: Tìm kiếm nội dung trong app không cần server

SQLite FTS5 (Full-Text Search version 5) là công cụ tìm kiếm toàn văn bản tích hợp sẵn trong SQLite, cho phép tìm kiếm nội dung văn bản nhanh chóng mà…

Xem thêm

Golang Concurrency Patterns: Worker Pool, Pipeline, Fan-out/Fan-in thực tế

Golang nổi tiếng với mô hình concurrency “share memory by communicating” thay vì “share memory by locking”. Nhưng pattern thông thường như worker pool, pipeline, fan-out/fan-in không tự động xuất…

Xem thêm

CI/CD GitHub Actions vs GitLab CI: Công cụ nào phù hợp dự án 2025?

CI/CD GitHub Actions vs GitLab CI: Công cụ nào phù hợp dự án 2025? CI/CD (Continuous Integration / Continuous Deployment) là xương sống của DevOps hiện đại — tự động…

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