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

asyncio trong Python là gì: event loop và lập trình bất đồng bộ

asyncio trong Python là gì và vì sao nó nhanh hơn threading là câu hỏi mà bất kỳ lập trình viên nào làm backend cũng gặp phải. Thư viện asyncio…

Xem thêm
Ví dụ cây đỏ đen hoàn chỉnh với các nút NIL tô đen ở đáy, mọi đường đi từ gốc xuống lá đều có cùng số nút đen

Cây đỏ đen (Red-Black Tree) là gì? Cấu trúc dữ liệu cân bằng

Cây đỏ đen (Red-Black Tree) là một cây tìm kiếm nhị phân tự cân bằng, nhờ vậy chiều cao cây luôn bị giới hạn ở mức O(log n) bất kể…

Xem thêm
Sơ đồ tổng quan các thao tác quản lý kho mã nguồn trong git: khởi tạo kho, thêm thay đổi vào vùng staging, tạo commit cục bộ rồi kéo và đẩy lên remote

Git internals là gì? Cách Git lưu blob, tree và commit bên trong

Git internals là gì? Đó là phần lõi bên trong Git — cách nó lưu blob, tree và commit dưới dạng đối tượng có địa chỉ SHA-1, vì sao có…

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