Menu

Discriminated union trong TypeScript: tagged union là gì

Discriminated union là union của các kiểu đối tượng có chung một thuộc tính nhãn dạng literal, như kind hoặc status. Kiểm tra nhãn sẽ thu hẹp kiểu của cả đối tượng. Tìm hiểu mẫu này, cách thu hẹp kiểu bằng switch, kiểm tra đầy đủ với never, và cách mô hình hóa kết quả API, trạng thái request và state machine.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Discriminated union (còn gọi là tagged union) là union của các kiểu đối tượng có chung một thuộc tính, gọi là nhãn (tag), với giá trị literal khác nhau ở mỗi thành viên. Kiểm tra nhãn cho TypeScript biết bạn đang có thành viên nào, và các thuộc tính khác của thành viên đó trở nên dùng được.

Bên trong case "circle", đọc shape.width sẽ là lỗi biên dịch, vì thành viên hình tròn không có width. Nhãn là dữ liệu bình thường: lúc chạy nó chỉ là một thuộc tính chuỗi, và switch là JavaScript thuần.

Điều gì làm nên một discriminated union

Thu hẹp kiểu theo nhãn hoạt động khi thỏa ba điều kiện:

  1. Mọi thành viên đều là kiểu đối tượng.
  2. Mọi thành viên có cùng một tên thuộc tính (discriminant). Tên do bạn chọn: kind, type, status và tag là các tên phổ biến.
  3. Ở mỗi thành viên, thuộc tính đó có kiểu literal: một literal chuỗi, số hoặc boolean, hoặc null/undefined.
type UiEvent =
  | { type: "click"; x: number; y: number }
  | { type: "keypress"; key: string }
  | { type: "scroll"; delta: number };

type ApiResponse =
  | { ok: true; data: string }    // boolean literal tags
  | { ok: false; error: string };

type Message =
  | { version: 1; text: string }  // number literal tags
  | { version: 2; text: string; lang: string };

Nếu một thành viên khai báo nhãn là string thường thay vì literal, việc so sánh nhãn không còn thu hẹp được union, và các thuộc tính riêng của từng thành viên vẫn nằm ngoài tầm với (TS2339).

Thu hẹp kiểu theo nhãn

Mọi phép kiểm tra trên nhãn mà TypeScript hiểu được đều thu hẹp kiểu của đối tượng: switch, if/else, === và !==, kể cả nhãn được destructure miễn là nó là const:

function describe(e: UiEvent): string {
  if (e.type === "click") return `click at ${e.x},${e.y}`;

  const { type } = e;        // destructured tags narrow too
  if (type === "keypress") return `key ${e.key}`;

  return `scroll by ${e.delta}`; // only "scroll" is left
}

Kiểm tra thuộc tính bằng "radius" in shape cũng thu hẹp được, nhưng so sánh nhãn dễ đọc hơn và cho phép kiểm tra đầy đủ.

Kiểm tra đầy đủ

Lợi ích lớn nhất của mẫu này lộ ra khi union lớn dần. Với kiểu trả về tường minh và không có default, TypeScript biết switch phải bao hết mọi nhãn, nên một thành viên mới mà thiếu case là lỗi biên dịch:

index.ts(7,30): error TS2366: Function lacks ending return statement and return type does not include 'undefined'.

Thông báo đó không nói case nào bị thiếu. Helper assertNever thì có, và nó còn ném lỗi lúc chạy nếu dữ liệu từ bên ngoài (JSON, một client cũ hơn) mang một nhãn mà kiểu nói là không thể tồn tại:

Thêm trạng thái thứ năm mà không có case, dòng assertNever(state) sẽ báo Argument of type '{ status: "..."; ... }' is not assignable to parameter of type 'never', nêu rõ tên thành viên. Xem thêm ở trang never.

Loại bỏ những trạng thái không thể xảy ra

RequestState ở trên thay cho một cách mô tả phổ biến nhưng yếu hơn:

// Every combination is allowed, including nonsense
type LooseState<T> = {
  loading: boolean;
  data?: T;
  error?: string;
};

const nonsense: LooseState<string[]> = { loading: true, data: ["a"], error: "timeout" };

Với các thuộc tính tùy chọn, trạng thái "đang tải mà vừa có dữ liệu vừa có lỗi" vẫn qua được kiểm tra kiểu, và mọi người đọc code phải đoán xem tổ hợp nào thật sự có thể xảy ra. Với discriminated union, data chỉ tồn tại ở trạng thái success và error chỉ có ở trạng thái error, nên code đọc state.data trước hết phải chứng minh nó đang ở trạng thái success. Bản thân kiểu đã mô tả các trạng thái hợp lệ.

Mô hình hóa kết quả: thành công hoặc thất bại

Một nhãn boolean là đủ cho "thành công hay không". Kiểu Result này là một cách phổ biến thay cho việc ném ngoại lệ với những thất bại có thể lường trước, như đầu vào không hợp lệ:

Nơi gọi không thể đọc result.value mà không kiểm tra result.ok trước, đó chính là phép kiểm tra rất dễ quên khi dùng ngoại lệ hoặc trả về null.

State machine và reducer

Hai discriminated union, một cho trạng thái và một cho action, mô tả một state machine. Reducer dùng switch theo action và trả về trạng thái tiếp theo; trình biên dịch kiểm tra rằng mỗi đối tượng trả về là một trạng thái hợp lệ:

{ ...state, name: "paused" } biên dịch được chỉ vì state đã được thu hẹp về thành viên đang phát trước đó, nên bản sao mang theo track và position. Chỉ trả về { name: "paused" } sẽ là lỗi: trạng thái tạm dừng cần có bài hát. Đây cũng là cách reducer của Redux và useReducer trong React được viết.

Cái bẫy mở rộng kiểu

Nhãn phải giữ nguyên kiểu literal. Một đối tượng được tạo vào một biến không có chú thích kiểu sẽ bị mở rộng nhãn thành string, và khi đó nó không khớp với thành viên nào:

type Shape = { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number };
declare function area(shape: Shape): number;

const c = { kind: "circle", radius: 2 }; // kind: string
area(c);
// error TS2345: Argument of type '{ kind: string; radius: number; }' is not assignable to parameter of type 'Shape'.

const ok1: Shape = { kind: "circle", radius: 2 };        // annotate the variable
const ok2 = { kind: "circle", radius: 2 } as const;      // or keep the literal with as const
area({ kind: "circle", radius: 2 });                     // or build it where Shape is expected

Quy tắc gốc rễ, rằng thuộc tính có thể thay đổi sẽ bị mở rộng kiểu, được giải thích ở trang literal type.

Câu hỏi thường gặp

Discriminated union trong TypeScript là gì?

Là union của các kiểu đối tượng mà mọi thành viên đều có cùng một thuộc tính (discriminant, hay nhãn) với kiểu literal khác nhau, ví dụ { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number }. Kiểm tra shape.kind === "circle" thu hẹp shape về thành viên hình tròn, nhờ vậy các thuộc tính khác của nó dùng được.

Union và discriminated union khác nhau thế nào?

Discriminated union là union có thêm một quy tắc: mọi thành viên có chung một thuộc tính với kiểu literal riêng biệt. Một union thường như Cat | Fish phải được thu hẹp bằng in hoặc type guard tự viết; discriminated union được thu hẹp chỉ bằng cách so sánh một thuộc tính, và một câu lệnh switch theo thuộc tính đó có thể được kiểm tra xem đã đủ mọi trường hợp chưa.

Làm sao để switch trên discriminated union được kiểm tra đầy đủ?

Hoặc cho hàm một kiểu trả về tường minh và không có default (khi đó thiếu một case là lỗi TS2366), hoặc thêm default: return assertNever(value) với function assertNever(x: never): never { throw ... }. Cách thứ hai nêu tên thành viên chưa được xử lý trong thông báo lỗi và còn ném lỗi lúc chạy nếu nhận được dữ liệu không mong đợi.

Vì sao discriminated union không thu hẹp được kiểu?

Nhãn phải là kiểu literal ở mọi thành viên. Nếu một đối tượng được tạo mà không có chú thích kiểu, kind của nó bị mở rộng thành string và nó không còn khớp với thành viên nào (TS2345/TS2322). Hãy sửa bằng chú thích kiểu, as const, hoặc tạo đối tượng ngay tại chỗ đang cần kiểu union. Một thành viên có kiểu kind: string cũng khiến union không thu hẹp được theo kind.

Discriminant có thể là boolean hoặc số không?

Có. Kiểu literal nào cũng được: chuỗi (kind: "circle"), số (version: 2), boolean (ok: true / ok: false), thậm chí null hoặc undefined. Nhãn dạng chuỗi phổ biến nhất vì dễ đọc khi ghi log hoặc gửi đi dưới dạng JSON.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu