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ả dataerror luô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:

  1. Mỗi type trong union có một literal type field chung (gọi là discriminant/tag)
  2. 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 kinddiscriminant. 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 actionaction.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 dataerror 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 = x trong 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.