A discriminated union (also called a tagged union) is a union of object types where every member shares a common literal-typed property called the discriminant or tag. TypeScript uses this property to narrow the union to a single member without any type assertion.
Discriminated union (còn gọi là tagged union) là một union của các kiểu object, trong đó mỗi thành viên chia sẻ một thuộc tính literal chung gọi là discriminant (hay tag). TypeScript dùng thuộc tính này để thu hẹp kiểu một cách chính xác mà không cần type assertion.
1// Each member has a 'kind' discriminant with a unique literal value2type Shape =3 | { kind: "circle"; radius: number }4 | { kind: "rectangle"; width: number; height: number }5 | { kind: "triangle"; base: number; height: number };67function area(shape: Shape): number {8 switch (shape.kind) {9 case "circle":10 // shape is { kind: "circle"; radius: number } here11 return Math.PI * shape.radius ** 2;12 case "rectangle":13 return shape.width * shape.height;14 case "triangle":15 return 0.5 * shape.base * shape.height;16 }17}The discriminant key can be any property name (kind, type, tag, status…) as long as it appears in every member and holds a unique literal value per member.
Khóa discriminant có thể là bất kỳ tên thuộc tính nào (kind, type, tag, status...) miễn là nó xuất hiện trong tất cả các thành viên của union và có giá trị literal duy nhất trong mỗi thành viên.
When you add a new variant to a union, you want TypeScript to force you to handle it. The assertNever pattern achieves this by assigning the default branch to a parameter typed as never — any unhandled variant causes a compile error.
Khi bạn thêm một variant mới vào union, bạn muốn TypeScript buộc bạn phải xử lý nó. Pattern assertNever làm điều đó bằng cách gán nhánh default cho tham số kiểu never — nếu có variant nào chưa được xử lý, TypeScript sẽ báo lỗi tại đó.
1function assertNever(value: never, message?: string): never {2 throw new Error(message ?? `Unhandled variant: ${JSON.stringify(value)}`);3}45type Shape =6 | { kind: "circle"; radius: number }7 | { kind: "rectangle"; width: number; height: number }8 | { kind: "triangle"; base: number; height: number };910function area(shape: Shape): number {11 switch (shape.kind) {12 case "circle": return Math.PI * shape.radius ** 2;assertNever should throw at runtime as a safety net: if you bypass the TypeScript error with a cast, the unhandled variant fails loudly at runtime instead of silently returning undefined.assertNever nên throw ở runtime vì nếu bạn bỏ qua lỗi TypeScript (ép kiểu), variant không xử lý sẽ gây lỗi runtime thay vì âm thầm trả về undefined.
Discriminated unions are the ideal tool for modeling mutually exclusive states such as an API request lifecycle (loading / success / error) or steps in a state machine.
Discriminated union là công cụ lý tưởng để mô hình hóa các trạng thái loại trừ lẫn nhau, chẳng hạn như vòng đời của một request API (đang tải / thành công / lỗi) hoặc các bước trong một state machine.
1// API result — impossible to access 'data' when status is "error"2type ApiResult<T> =3 | { status: "loading" }4 | { status: "success"; data: T }5 | { status: "error"; error: Error };67function render<T>(result: ApiResult<T>): string {8 switch (result.status) {9 case "loading": return "Loading…";10 case "success": return JSON.stringify(result.data); // data available here11 case "error": return `Error: ${result.error.message}`; // error available here12 default: return assertNever(result);This pattern makes invalid states unrepresentable — you cannot have both data and error simultaneously, and you cannot accidentally access data when status is "loading".
Mô hình này làm cho các trạng thái không hợp lệ trở nên không thể biểu diễn — bạn không thể có cả data lẫn error cùng lúc, và bạn không thể vô tình truy cập data khi status là 'loading'.
TypeScript narrows discriminated unions in both switch and if/else. After each discriminant check, TypeScript knows exactly which union member is in scope.
TypeScript thu hẹp kiểu discriminated union trong cả câu lệnh switch và if/else. Sau mỗi kiểm tra discriminant, TypeScript biết chính xác thành viên nào của union đang được xử lý.
1type Notification =2 | { kind: "email"; to: string; subject: string }3 | { kind: "sms"; to: string; body: string }4 | { kind: "push"; token: string; title: string };56// if/else narrowing7function sendNotification(n: Notification): void {8 if (n.kind === "email") {9 // n: { kind: "email"; to: string; subject: string }10 console.log(`Email to ${n.to}: ${n.subject}`);11 } else if (n.kind === "sms") {12 // n: { kind: "sms"; to: string; body: string }kind is typed as string (broad), TypeScript cannot narrow because any value qualifies. Only unique literal types (string literals, number literals, boolean) let TypeScript eliminate other members.Tại sao thuộc tính discriminant phải là literal type? Nếu kind là string (rộng), TypeScript không thể thu hẹp kiểu vì bất kỳ giá trị nào cũng có thể là string. Chỉ có các kiểu literal duy nhất (string literal, number literal, boolean) mới cho phép TypeScript loại trừ các thành viên khác.
If the discriminant property is declared with a broad type like string or number, TypeScript cannot use it for narrowing. Always use specific literal values.
Nếu thuộc tính discriminant được khai báo với kiểu rộng như string hoặc number, TypeScript không thể dùng nó để thu hẹp kiểu. Luôn dùng giá trị literal cụ thể.
1// WRONG — 'type' is string, not a literal — no narrowing possible2type BadAction =3 | { type: string; payload: string }4 | { type: string; id: number };56function handle(a: BadAction) {7 if (a.type === "add") {8 a.payload; // Error: Property 'payload' does not exist on type 'BadAction'9 }10}1112// CORRECT — each type is a distinct string literalassertNever in the default branch. A silent default: break hides unhandled variants. Always use assertNever to get a compile error when the union grows.Một lỗi phổ biến khác là quên xử lý tất cả các variant khi không dùng assertNever. Thêm mặc định không làm gì (default: break) che giấu lỗi logic. Hãy luôn dùng assertNever trong nhánh default để đảm bảo tính đầy đủ.
Discriminated union là union của các object type chia sẻ thuộc tính literal chung gọi là discriminant
switch or if/elseTypeScript thu hẹp kiểu chính xác khi kiểm tra thuộc tính discriminant trong switch hoặc if/else
assertNever in the default branch to enforce exhaustiveness at compile timeDùng assertNever trong nhánh default của switch để đảm bảo kiểm tra đầy đủ tại thời điểm biên dịch
string will not enable narrowingDiscriminant phải là literal type (string/number/boolean literal) — kiểu rộng như string sẽ không hoạt động
Discriminated union làm cho các trạng thái không hợp lệ trở nên không thể biểu diễn — lý tưởng cho kết quả API, Redux actions và state machines
assertNever automatically creates compile errors at every site that needs updatingThêm variant mới vào union kết hợp với assertNever sẽ tự động tạo ra lỗi biên dịch ở tất cả các nơi cần cập nhật