Discriminated union trong TypeScript cho state và API response

1. Tổng quan

Nhiều lỗi giao diện đến từ những state lẽ ra không bao giờ xảy ra. Ví dụ isLoading đang là true nhưng error cũng có giá trị. Hoặc data là undefined dù request đã báo thành công.

Bài này hướng dẫn bạn dùng discriminated union của TypeScript để mô hình hoá state tải dữ liệu và API response. Đọc xong, bạn sẽ biết cách:

  • định nghĩa union có trường phân biệt,
  • để compiler tự thu hẹp kiểu (narrowing),
  • bắt buộc xử lý đủ mọi nhánh bằng exhaustiveness check,
  • validate response lúc runtime bằng Zod,
  • viết test cho các chuyển đổi state.

2. Vấn đề với “túi field optional”

Cách viết quen thuộc là gom mọi thứ vào một interface với các field optional:

interface OrderState {
  isLoading: boolean;
  error?: string;
  order?: { id: string; total: number };
}

const state: OrderState = {
  isLoading: true,
  error: "Timeout",
  order: { id: "A1", total: 120 },
};

Đoạn code trên compile bình thường. Tuy vậy, nó mô tả một state vô nghĩa: vừa đang tải, vừa lỗi, vừa có dữ liệu.

Với ba field kiểu này, bạn có tới 2 × 2 × 2 = 8 tổ hợp. Thực tế chỉ khoảng 4 tổ hợp là hợp lệ. Khi đọc state, bạn cũng phải viết thêm state.order?.total hoặc state.order! ở khắp nơi. Khi có lỗi, compiler không giúp được gì.

3. Discriminated union là gì

Discriminated union là union của nhiều kiểu object. Mỗi kiểu có chung một field mang literal type khác nhau. Field này gọi là discriminant, hay “tag”:

type Order = { id: string; total: number };

type OrderState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; order: Order }
  | { status: "error"; message: string };

Ở đây status là discriminant. Mỗi nhánh chỉ khai báo những field có ý nghĩa với chính nó. Nhánh success có order, nhánh error có message. State “đang tải mà lại có lỗi” giờ không thể biểu diễn được nữa:

const bad: OrderState = { status: "loading", message: "Timeout" };
error TS2353: Object literal may only specify known properties,
and 'message' does not exist in type '{ status: "loading"; }'.

3.1. Narrowing theo discriminant

Khi bạn kiểm tra giá trị của discriminant, TypeScript tự thu hẹp kiểu trong nhánh tương ứng:

function describe(state: OrderState): string {
  if (state.status === "success") {
    return `Đơn ${state.order.id}: ${state.order.total}đ`;
  }
  if (state.status === "error") {
    return `Lỗi: ${state.message}`;
  }
  return state.status === "loading" ? "Đang tải..." : "Chưa bắt đầu";
}

console.log(describe({ status: "success", order: { id: "A1", total: 120 } }));
console.log(describe({ status: "error", message: "Timeout" }));
Đơn A1: 120đ
Lỗi: Timeout

Bên trong nhánh if (state.status === "success"), kiểu của state là { status: "success"; order: Order }. Vì vậy bạn truy cập state.order.id được ngay, không cần ?. hay !. Nếu bạn gõ state.message trong nhánh này, compiler sẽ báo lỗi.

4. Exhaustiveness check: không bỏ sót trạng thái

Union chỉ thật sự hữu ích khi thêm một nhánh mới khiến compiler chỉ ra mọi chỗ chưa xử lý nhánh đó. Cách phổ biến là dùng switch kết hợp với kiểu never:

function assertNever(value: never): never {
  throw new Error(`Trạng thái không được xử lý: ${JSON.stringify(value)}`);
}

function render(state: OrderState): string {
  switch (state.status) {
    case "idle":
      return "<button>Tải đơn hàng</button>";
    case "loading":
      return "<spinner />";
    case "success":
      return `<p>Tổng: ${state.order.total}đ</p>`;
    case "error":
      return `<p class="error">${state.message}</p>`;
    default:
      return assertNever(state);
  }
}

Sau khi đi qua đủ bốn case, kiểu của state ở nhánh default là never. Lời gọi assertNever(state) vì thế hợp lệ.

Giả sử sau này bạn thêm nhánh { status: "cancelled" } vào OrderState nhưng quên cập nhật render:

error TS2345: Argument of type '{ status: "cancelled"; }'
is not assignable to parameter of type 'never'.

Lỗi xuất hiện lúc build, trước khi người dùng nhìn thấy màn hình trắng. Nếu dữ liệu lạ vẫn lọt vào lúc runtime, chẳng hạn từ một API không đáng tin, assertNever sẽ ném lỗi rõ ràng thay vì im lặng trả về undefined.

Lưu ý: Nếu bạn bật "strict": true và khai báo kiểu trả về tường minh (: string), bỏ thiếu một case cũng gây lỗi “Function lacks ending return statement”. Dù vậy, assertNever vẫn đáng dùng. Nó cho thông báo lỗi rõ hơn và hoạt động cả với hàm trả về void.

5. Mô hình hoá API response

API thường trả về hai dạng: thành công hoặc thất bại. Bạn có thể định nghĩa một kiểu Result dùng chung:

type Result<T, E = ApiError> =
  | { ok: true; data: T }
  | { ok: false; error: E };

type ApiError =
  | { kind: "network"; cause: string }
  | { kind: "http"; status: number }
  | { kind: "invalid"; issues: string[] };

Discriminant không bắt buộc phải là chuỗi. ok: true và ok: false là hai literal type của boolean, nên narrowing vẫn hoạt động. ApiError cũng là một discriminated union lồng bên trong, phân biệt bằng kind.

5.1. Validate response lúc runtime với Zod

Kiểu TypeScript biến mất sau khi compile. Nếu bạn viết (await res.json()) as Order, bạn chỉ đang “hứa” với compiler, không hề kiểm tra gì cả. Để đảm bảo dữ liệu từ server đúng hình dạng, hãy validate bằng Zod:

npm install zod

Giả sử server trả về payload có discriminant riêng là type:

import { z } from "zod";

const OrderSchema = z.object({ id: z.string(), total: z.number() });

const OrderResponseSchema = z.discriminatedUnion("type", [
  z.object({ type: z.literal("found"), order: OrderSchema }),
  z.object({ type: z.literal("not_found"), orderId: z.string() }),
]);

type OrderResponse = z.infer<typeof OrderResponseSchema>;

z.discriminatedUnion("type", [...]) tạo schema dựa trên field type. Zod đọc giá trị type trước rồi mới validate theo đúng nhánh, nên thông báo lỗi chính xác hơn z.union. z.infer sinh ra kiểu TypeScript tương ứng, nên bạn không phải viết kiểu hai lần.

Tiếp theo là hàm gọi API, bọc mọi lỗi vào Result:

async function fetchOrder(id: string): Promise<Result<OrderResponse>> {
  let res: Response;
  try {
    res = await fetch(`/api/orders/${id}`);
  } catch (e) {
    return { ok: false, error: { kind: "network", cause: String(e) } };
  }

  if (!res.ok) {
    return { ok: false, error: { kind: "http", status: res.status } };
  }

  const parsed = OrderResponseSchema.safeParse(await res.json());
  if (!parsed.success) {
    const issues = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`);
    return { ok: false, error: { kind: "invalid", issues } };
  }
  return { ok: true, data: parsed.data };
}

Hàm này không bao giờ ném lỗi ra ngoài. Mọi tình huống đều được thể hiện trong kiểu trả về. Bản thân safeParse cũng trả về một discriminated union với discriminant là success. Đây chính là pattern bạn đang học.

Phía gọi hàm xử lý kết quả như sau:

async function showOrder(id: string): Promise<string> {
  const result = await fetchOrder(id);
  if (!result.ok) {
    switch (result.error.kind) {
      case "network": return "Mất kết nối, thử lại sau.";
      case "http":    return `Server trả về ${result.error.status}.`;
      case "invalid": return "Dữ liệu không hợp lệ.";
      default:        return assertNever(result.error);
    }
  }
  return result.data.type === "found"
    ? `Tổng: ${result.data.order.total}đ`
    : `Không tìm thấy đơn ${result.data.orderId}`;
}

Sau dòng if (!result.ok) { ... }, TypeScript biết result chắc chắn là nhánh ok: true. Vì vậy result.data truy cập được trực tiếp.

6. Chuyển đổi state bằng reducer

Discriminated union rất hợp với reducer, ví dụ useReducer của React hay một store tự viết. Cả state lẫn action đều là union:

type OrderAction =
  | { type: "FETCH" }
  | { type: "RESOLVE"; order: Order }
  | { type: "REJECT"; message: string }
  | { type: "RESET" };

function orderReducer(state: OrderState, action: OrderAction): OrderState {
  switch (action.type) {
    case "FETCH":
      return state.status === "loading" ? state : { status: "loading" };
    case "RESOLVE":
      return state.status === "loading"
        ? { status: "success", order: action.order }
        : state;
    case "REJECT":
      return state.status === "loading"
        ? { status: "error", message: action.message }
        : state;
    case "RESET":
      return { status: "idle" };
    default:
      return assertNever(action);
  }
}

Reducer này mã hoá luôn luật chuyển trạng thái. RESOLVE chỉ có tác dụng khi đang loading. Nhờ vậy, một response đến muộn sau khi người dùng đã RESET sẽ bị bỏ qua. Bạn không cần thêm cờ phụ nào.

7. Viết test cho hành vi chính

Bài dùng Vitest. Cú pháp expect của nó tương tự AssertJ và Jest:

npm install -D vitest
import { describe, it, expect } from "vitest";
import { orderReducer, render } from "./order";

describe("orderReducer", () => {
  const order = { id: "A1", total: 120 };

  it("chuyển từ loading sang success khi RESOLVE", () => {
    const next = orderReducer({ status: "loading" }, { type: "RESOLVE", order });
    expect(next).toEqual({ status: "success", order });
  });

  it("bỏ qua RESOLVE đến muộn khi đã idle", () => {
    const next = orderReducer({ status: "idle" }, { type: "RESOLVE", order });
    expect(next).toEqual({ status: "idle" });
  });

  it("render hiển thị thông báo lỗi", () => {
    expect(render({ status: "error", message: "Timeout" }))
      .toBe('<p class="error">Timeout</p>');
  });
});
npx vitest run
 ✓ order.test.ts (3 tests)
   ✓ orderReducer > chuyển từ loading sang success khi RESOLVE
   ✓ orderReducer > bỏ qua RESOLVE đến muộn khi đã idle
   ✓ orderReducer > render hiển thị thông báo lỗi

 Test Files  1 passed (1)
      Tests  3 passed (3)

Test thứ hai là test quan trọng nhất. Nó khoá lại hành vi chống race condition, loại lỗi mà mô hình “túi field optional” rất dễ gặp.

8. Lỗi hay gặp và khi nào không nên dùng

Discriminant bị “nới” thành string. Khi bạn tạo object trong một biến trung gian, TypeScript có thể suy ra kiểu string thay vì literal:

const s = { status: "loading" };
const state: OrderState = s;
error TS2322: Type '{ status: string; }' is not assignable to type 'OrderState'.

Bạn có ba cách sửa: khai báo kiểu ngay từ đầu (const s: OrderState = ...), dùng as const, hoặc dùng satisfies OrderState.

Destructure sớm làm mất liên kết. Nếu bạn viết let { status, ...rest } = state, TypeScript không còn biết rest thuộc nhánh nào khi bạn kiểm tra status. Narrowing qua destructuring chỉ hoạt động khi biến được khai báo bằng const và bạn không gán lại.

Dùng toán tử in thay cho discriminant. Kiểm tra "order" in state vẫn narrowing được. Tuy nhiên, cách này dễ vỡ khi hai nhánh có cùng một tên field. Hãy luôn kiểm tra qua discriminant.

Discriminant optional hoặc có thể bị sửa. Đừng khai báo status?: .... Cũng đừng gán state.status = "error" trên object có sẵn. Thay vào đó, hãy tạo object mới cho mỗi state. Bạn có thể đánh dấu field là readonly để compiler giữ đúng quy tắc này.

Ép kiểu thay vì validate. Viết as OrderResponse cho dữ liệu từ mạng sẽ xoá bỏ mọi lợi ích ở trên. Ở ranh giới hệ thống, luôn validate dữ liệu lúc runtime.

Lưu ý: Không nên dùng discriminated union khi các field thật sự độc lập với nhau. Ví dụ, một form có email?, phone?, note? có thể được điền theo mọi tổ hợp. Khi đó, field optional là mô hình đúng. Ép chúng thành union chỉ làm số nhánh tăng nhanh mà không đem lại thêm an toàn nào.

9. Kết luận

Discriminated union giúp bạn biến những state không hợp lệ thành lỗi compile. Bạn đã thấy cách:

  • định nghĩa union với một discriminant literal,
  • để TypeScript tự narrowing theo discriminant,
  • dùng assertNever để bắt buộc xử lý đủ mọi nhánh,
  • mô hình hoá API response bằng Result và validate dữ liệu bằng z.discriminatedUnion,
  • áp dụng cùng pattern cho reducer và kiểm chứng bằng test.

Quy tắc ngắn gọn là: nếu các field chỉ có nghĩa khi đi cùng nhau, hãy đặt chúng vào cùng một nhánh của union. Lần tới, khi định viết isLoading cạnh error? và data?, bạn hãy thử thay bằng một field status.

typescriptdiscriminated uniontype safetystate managementapi