narrowingdiscriminated-unionunionMenengah4 mnt baca

Discriminated Union

Pola union paling rapi: bedakan varian dengan properti penanda (discriminant).

Kenapa ini pola union paling rapi

Masalah operator in adalah dia menebak dari properti yang kebetulan unik. Discriminated union membalik logikanya: kamu merancang union dengan properti penanda (discriminant) yang nilainya literal unik per varian. Compiler lalu bisa mempersempit hanya dari nilai satu properti itu. Dan yang lebih penting: dia bisa memaksa kamu menangani semua varian, tidak ada yang ke-skip diam-diam.

Alur narrowing langkah demi langkah

Skenario nyata: status pesanan di aplikasi kasir.

typescript
interface Menunggu { status: "menunggu"; dibuatPada: Date }
interface Diproses { status: "diproses"; kasir: string }
interface Selesai { status: "selesai"; totalBayar: number }
interface Batal { status: "batal"; alasan: string }

type Pesanan = Menunggu | Diproses | Selesai | Batal;

function labelStatus(p: Pesanan): string {
  // Satu switch pada discriminant, tiap case otomatis menyempit.
  switch (p.status) {
    case "menunggu":
      return `antri sejak ${p.dibuatPada.toLocaleTimeString()}`;
      // p: Menunggu. p.kasir TIDAK bisa diakses di sini.
    case "diproses":
      return `dikerjakan ${p.kasir}`; // p: Diproses
    case "selesai":
      return `lunas Rp${p.totalBayar}`; // p: Selesai
    case "batal":
      return `batal: ${p.alasan}`; // p: Batal
  }
}

Di setiap case, compiler sudah tahu varian mana yang sedang ditangani, jadi properti spesifik varian itu bisa diakses langsung. Coba akses p.kasir di case "menunggu", langsung error: compiler tahu itu bukan Diproses.

Exhaustiveness check: jebakan varian baru

Suatu hari product minta status baru "dikirim". Tanpa pengaman, semua switch lama diam-diam tidak menanganinya, dan bug-nya baru ketahuan di production. Solusinya: paksa compiler protes dengan never.

typescript
function labelStatusAman(p: Pesanan): string {
  switch (p.status) {
    case "menunggu": return "antri";
    case "diproses": return "dikerjakan";
    case "selesai": return "lunas";
    case "batal": return "batal";
    default:
      const takTerduga: never = p; // <-- pengaman
      throw new Error(`status tak dikenal: ${takTerduga}`);
  }
}

Logikanya: kalau semua case sudah menangani semua varian, maka di default tidak ada yang tersisa, jadi p bertipe never. Begitu Pesanan ketambahan varian Dikirim, p di default bukan never lagi, dan compiler error:

text
error TS2322: Type 'Dikirim' is not assignable to type 'never'.

Itu fitur, bukan bug: error ini menunjuk persis fungsi mana yang belum menangani varian baru. Di codebase besar dengan puluhan switch, ini penyelamat yang nyata.

Kapan dipakai di project nyata

  • State async di frontend: { status: "loading" } | { status: "sukses"; data } | { status: "gagal"; error } adalah pola standar fetch data di React, Zustand, atau Redux. Komponen render beda UI per status tanpa isLoading boolean yang gampang inkonsisten.
  • Hasil operasi di backend: service me-return Berhasil | GagalValidasi | TidakKetemu, lalu handler HTTP memetakan tiap varian ke status code yang beda (200, 400, 404). Tidak ada lagi throw sembarangan.
  • Sistem event/pesan: bedakan PesanTeks | PesanGambar | PesanLokasi di bot WhatsApp atau aplikasi chat dari field tipe, masing-masing dengan payload yang beda.

Catatan teknis: Discriminant tidak harus string literal, tapi string literal paling enak dibaca dan paling gampang di-switch. Hindari boolean sebagai discriminant (isOk: true | false): cuma muat dua varian dan gampang kebalik artinya saat dibaca cepat.

Tantangan

State machine pembayaran

Buat discriminated union Bayar dengan varian: { tahap: "pilih" }, { tahap: "proses"; idTransaksi: string }, { tahap: "selesai"; struk: string }, { tahap: "batal"; alasan: string }. Fungsi pesan(b: Bayar): string memakai switch + exhaustiveness check never.

typescript
type Bayar =
  | { tahap: "pilih" }
  | { tahap: "proses"; idTransaksi: string }
  | { tahap: "selesai"; struk: string }
  | { tahap: "batal"; alasan: string };

function pesan(b: Bayar): string {
  // switch + default never
}