Anda me-refactor tipe shape, menambahkan varian baru, dan TypeScript tetap hijau. CI Anda lulus. Deploy Anda keluar. Lalu seorang pengguna menemui cabang runtime yang mengembalikan undefined, dan aplikasi Anda error di production.

Pelakunya hampir pasti sebuah switch statement. switch TypeScript tidak punya jaminan exhaustiveness. Tambahkan anggota baru ke union, dan setiap switch di atas union itu menjadi tidak lengkap secara diam-diam. Compiler tidak akan menghentikan Anda. Compiler bahkan tidak akan memperingatkan Anda. Inilah bagian yang membuat orang terjebak: TypeScript mengetahui keseluruhan kemungkinan, tetapi switch statement tidak ikut dalam exhaustiveness checking.

Apa sebenarnya exhaustiveness checking itu

Exhaustiveness berarti compiler dapat membuktikan bahwa setiap nilai yang mungkin sudah ditangani. Bahasa seperti Rust dan OCaml menegakkan ini saat compile time. TypeScript tidak, setidaknya untuk switch statement.

Perhatikan sebuah discriminated union:

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    // triangle is missing — and TypeScript is fine with it
  }
}

Ini berhasil di-compile. Ini juga mengembalikan undefined ketika shape.kind === "triangle". Signature fungsi menjanjikan number, tetapi implementasinya tidak memberikannya. TypeScript tidak menandai kasus yang hilang karena blok switch tidak diperiksa kelengkapannya.

Pola object lookup

Pengganti paling sederhana adalah object map biasa. Setiap key memetakan ke fungsi handler. Anda mengakses object dengan discriminant, dan compiler memeriksa key tersebut ada.

const areaHandlers: Record<Shape["kind"], (shape: Shape) => number> = {
  circle: (s) => Math.PI * (s as Extract<Shape, { kind: "circle" }>).radius ** 2,
  rectangle: (s) =>
    (s as Extract<Shape, { kind: "rectangle" }>).width *
    (s as Extract<Shape, { kind: "rectangle" }>).height,
  triangle: (s) =>
    0.5 *
    (s as Extract<Shape, { kind: "triangle" }>).base *
    (s as Extract<Shape, { kind: "triangle" }>).height,
};

function area(shape: Shape): number {
  return areaHandlers[shape.kind](shape);
}

Ini lebih baik. Jika Anda menambahkan "polygon" ke Shape, TypeScript akan mengeluh bahwa areaHandlers kehilangan sebuah key. Error menunjuk ke tempat yang tepat, dan itu terjadi saat compile time.

Tapi cast-nya jelek. Kita bisa lebih baik.

Handler type-safe tanpa casting

Triknya adalah memetakan setiap varian ke fungsi yang menerima tipe narrowed secara langsung. Anda menggunakan mapped type untuk mempertahankan hubungan antara key dan varian shape tertentu.

type ShapeKind = Shape["kind"];

type AreaMap = {
  [K in ShapeKind]: (shape: Extract<Shape, { kind: K }>) => number;
};

const areaHandlers: AreaMap = {
  circle: (s) => Math.PI * s.radius ** 2,
  rectangle: (s) => s.width * s.height,
  triangle: (s) => 0.5 * s.base * s.height,
};

function area(shape: Shape): number {
  const handler = areaHandlers[shape.kind];
  return handler(shape as never);
}

Sekarang setiap handler menerima argumen yang sudah narrowed dengan benar. Tidak ada cast manual di dalam fungsi. as never di baris terakhir adalah kekurangan kecil, tetapi itu terlokalisasi dan aman. Nilai sebenarnya adalah bahwa areaHandlers harus berisi setiap key di ShapeKind. Hapus satu, atau tambahkan varian baru ke Shape, dan build akan gagal.

Exhaustiveness checking dengan never assert

Beberapa tim lebih suka mempertahankan switch tetapi menambahkan guard exhaustiveness saat compile time. Ini adalah jalan tengah yang pragmatis. Anda menambahkan cabang default yang menerima never, memaksa compiler untuk memverifikasi bahwa semua kasus sudah ditangani.

function assertNever(x: never): never {
  throw new Error("Unexpected value: " + x);
}

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return 0.5 * shape.base * shape.height;
    default:
      return assertNever(shape);
  }
}

Jika Anda menambahkan jenis shape baru dan lupa menambahkan kasus, cabang default menerima nilai non-never. TypeScript error karena Anda tidak bisa menetapkan tipe konkret ke never. Error menunjuk ke switch, yang persis di tempat yang Anda inginkan.

Pola ini banyak digunakan di Sentry dan codebase TypeScript besar lainnya. Ini tidak memerlukan mengubah struktur kode Anda. Ini hanya membuat compiler bekerja untuk Anda, bukan melawan Anda.

Pattern matching dengan library pihak ketiga

Jika Anda menginginkan sesuatu yang lebih mirip match di Rust, library seperti ts-pattern menyediakan pattern matching dengan exhaustiveness checking bawaan.

import { match, P } from "ts-pattern";

function area(shape: Shape): number {
  return match(shape)
    .with({ kind: "circle" }, (s) => Math.PI * s.radius ** 2)
    .with({ kind: "rectangle" }, (s) => s.width * s.height)
    .with({ kind: "triangle" }, (s) => 0.5 * s.base * s.height)
    .exhaustive();
}

Panggilan .exhaustive() adalah kuncinya. Jika ada varian yang hilang, TypeScript melaporkan error. Library ini juga mendukung nested patterns, guards, dan wildcards, yang dapat menggantikan blok switch bersarang yang kompleks.

Ini powerful, tetapi menambahkan dependency. Untuk discriminated union tunggal, ts-pattern berlebihan. Untuk codebase dengan pattern matching yang intens, sebanding dengan ukuran bundle-nya.

Di mana switch statement masih menang

Switch tidak selalu salah. Jika kasus-kasus Anda perlu fall through, atau jika logikanya imperatif dengan early return dan side effects, switch bisa lebih mudah dibaca daripada lookup table atau rangkaian panggilan .with().

Masalahnya bukan sintaksnya. Ini adalah kurangnya exhaustiveness checking. Jika Anda menggunakan switch, tambahkan guard assertNever. Itu dua baris kode dan menutup celah keamanan.

Cara mengadopsi ini tanpa menulis ulang semuanya

Anda tidak perlu me-refactor semua switch Anda hari ini. Berikut urutan operasi yang praktis:

  1. Tambahkan assertNever ke utilitas bersama Anda. Gunakan di blok switch baru.
  2. Ketika Anda menyentuh switch yang sudah ada di atas union, tambahkan guard tersebut.
  3. Untuk kode baru di atas discriminated union, lebih suka pola object-map jika logikanya murni dan setiap kasus independen.
  4. Evaluasi ts-pattern jika Anda menemukan diri Anda menulis switch bersarang atau logika matching kompleks lebih dari beberapa kali.

Tujuannya bukan untuk menghilangkan switch statement. Tujuannya adalah membuat compiler menangkap bug sebelum pengguna Anda menemukannya.

Jika Anda ingin melihat ini dalam action, coba hapus satu handler dari contoh AreaMap dan lihat tsc mengeluh. Garis merah berkelok-kelok itu adalah perbedaan antara deploy Jumat yang tenang dan halaman pagerduty akhir pekan.