Discriminated union (disebut juga tagged union) adalah union dari object type yang semuanya punya satu property yang sama, yaitu tag, dengan nilai literal yang berbeda di setiap anggota. Memeriksa tag-nya memberi tahu TypeScript anggota mana yang sedang kamu pegang, dan property lain dari anggota itu menjadi bisa diakses.
Di dalam case "circle", membaca shape.width akan menjadi error kompilasi, karena anggota circle tidak punya width. Tag-nya adalah data biasa: saat runtime ia hanyalah property string, dan switch-nya adalah JavaScript biasa.
Apa yang Membuat Sebuah Union Menjadi Discriminated
Penyempitan berdasarkan tag berhasil kalau tiga syarat ini terpenuhi:
- Setiap anggota adalah object type.
- Setiap anggota punya nama property yang sama (discriminant). Namanya bebas:
kind,type,status, dantagadalah yang umum. - Di setiap anggota, property itu bertipe literal: literal string, number, atau boolean, atau
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 };
Kalau satu anggota mendeklarasikan tag sebagai string biasa, bukan literal, membandingkan tag tidak lagi mempersempit union, dan property khusus anggota tetap tidak bisa dijangkau (TS2339).
Mempersempit Berdasarkan Tag
Pemeriksaan apa pun pada tag yang dipahami TypeScript akan mempersempit objeknya: switch, if/else, === dan !==, bahkan tag hasil destructuring selama ia berupa 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
}
Memeriksa keberadaan property dengan "radius" in shape juga mempersempit, tetapi membandingkan tag lebih jelas dibaca dan memungkinkan pemeriksaan menyeluruh.
Pemeriksaan Menyeluruh
Manfaat terbesar pola ini terlihat saat union-nya bertambah. Dengan tipe kembalian yang eksplisit dan tanpa default, TypeScript tahu bahwa switch harus mencakup setiap tag, jadi anggota baru tanpa case menjadi error kompilasi:
index.ts(7,30): error TS2366: Function lacks ending return statement and return type does not include 'undefined'.
Pesan itu tidak menyebutkan case mana yang terlewat. Helper assertNever menyebutkannya, dan juga melempar error saat runtime kalau data dari luar (JSON, client versi lama) membawa tag yang menurut tipenya tidak mungkin ada:
Tambahkan status kelima tanpa case, dan baris assertNever(state) melaporkan Argument of type '{ status: "..."; ... }' is not assignable to parameter of type 'never', dengan menyebutkan anggotanya. Penjelasan lebih lanjut ada di halaman never.
Membuat State yang Mustahil Benar-benar Mustahil
RequestState di atas menggantikan bentuk yang umum tetapi lebih lemah:
// 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" };
Dengan field opsional, "sedang loading dengan data dan error" lolos pemeriksaan tipe, dan setiap pembaca harus menebak kombinasi mana yang benar-benar bisa terjadi. Dengan discriminated union, data hanya ada di state success dan error hanya di error, jadi kode yang membaca state.data harus membuktikan dulu bahwa ia berada di state success. Tipenya sendiri mendokumentasikan state yang valid.
Memodelkan Hasil: Berhasil atau Gagal
Tag boolean sudah cukup untuk "berhasil atau tidak". Tipe Result ini adalah alternatif umum dari melempar exception untuk kegagalan yang memang diperkirakan, seperti input yang tidak valid:
Pemanggil tidak bisa membaca result.value tanpa memeriksa result.ok lebih dulu, dan itulah pemeriksaan yang mudah terlupa kalau memakai exception atau nilai kembalian null.
State Machine dan Reducer
Dua discriminated union, satu untuk state dan satu untuk action, mendeskripsikan sebuah state machine. Reducer melakukan switch pada action dan mengembalikan state berikutnya; compiler memeriksa bahwa setiap objek yang dikembalikan adalah state yang valid:
{ ...state, name: "paused" } hanya lolos kompilasi karena state sudah dipersempit menjadi anggota playing lebih dulu, sehingga salinannya membawa track dan position. Mengembalikan { name: "paused" } saja akan menjadi error: state paused butuh track. Ini bentuk yang sama yang dipakai reducer Redux dan useReducer di React.
Jebakan Pelebaran Tipe
Tag-nya harus tetap bertipe literal. Objek yang dibuat ke dalam variabel tanpa anotasi akan dilebarkan tag-nya menjadi string, lalu tidak cocok dengan anggota mana pun:
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
Aturan dasarnya, bahwa property yang bisa diubah akan dilebarkan tipenya, dijelaskan di halaman tipe literal.
Pertanyaan yang sering diajukan
Apa itu discriminated union di TypeScript?
Union dari object type di mana setiap anggotanya punya property yang sama (discriminant atau tag) dengan tipe literal yang berbeda, misalnya { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number }. Memeriksa shape.kind === "circle" mempersempit shape menjadi anggota circle, sehingga property lainnya bisa diakses.
Apa bedanya union dan discriminated union?
Discriminated union adalah union dengan satu aturan tambahan: semua anggotanya berbagi sebuah property dengan tipe literal yang unik. Union biasa seperti Cat | Fish harus dipersempit dengan in atau type guard kustom; discriminated union dipersempit dengan membandingkan satu property, dan switch atas property itu bisa diperiksa kelengkapannya.
Bagaimana cara membuat switch atas discriminated union diperiksa secara menyeluruh?
Beri fungsi itu tipe kembalian yang eksplisit tanpa default (case yang terlewat lalu menghasilkan error TS2366), atau tambahkan default: return assertNever(value) dengan function assertNever(x: never): never { throw ... }. Bentuk kedua menyebutkan anggota yang belum ditangani di pesan error dan juga melempar error saat runtime kalau data yang tidak terduga datang.
Kenapa discriminated union-ku tidak menyempit?
Tag-nya harus bertipe literal di setiap anggota. Kalau sebuah objek dibuat tanpa anotasi, kind-nya dilebarkan menjadi string dan tidak lagi cocok dengan anggota mana pun (TS2345/TS2322). Perbaiki dengan anotasi, as const, atau dengan membuatnya di tempat yang mengharapkan tipe union itu. Anggota yang bertipe kind: string juga membuat union tidak bisa dipersempit lewat kind.
Apakah discriminant bisa berupa boolean atau number?
Bisa. Tipe literal apa pun bisa dipakai: string (kind: "circle"), number (version: 2), boolean (ok: true / ok: false), bahkan null atau undefined. Tag berupa string paling umum karena mudah dibaca saat dicatat di log atau dikirim sebagai JSON.