Anda menambahkan sebuah state. Anda lupa sebuah handler. Tidak ada yang memperingatkan Anda.
State machine dimulai dengan bersih. Tiga nilai, tiga cabang switch. Lalu logika retry menambahkan state keempat. Partial failure menambahkan state kelima. Anda memperbarui reducer, tapi melewatkan komponen status badge, analytics mapper, dan export formatter.
Semuanya berhasil dikompilasi. Bug muncul dua sprint kemudian ketika pengguna menemukan layar kosong dalam corner case yang tidak pernah Anda uji secara manual.
TypeScript bisa membuat ini mustahil. Bukan dengan aturan linter. Bukan dengan test. Dengan sistem type itu sendiri.
Apa yang sebenarnya dimaksud dengan exhaustiveness checking
Exhaustiveness checking adalah compiler yang membuktikan Anda telah menangani setiap varian dari sebuah type. Rust dan OCaml melakukan ini secara default. TypeScript juga memilikinya, tapi Anda harus memintanya.
Triknya menggabungkan dua hal: discriminated unions, dan sebuah helper yang hanya menerima type never.
Cara discriminated unions memodelkan state
Discriminated union adalah sebuah type TypeScript di mana setiap varian memiliki properti literal bersama, yang disebut discriminant. Untuk state machine, ini hampir selalu field status, kind, atau type.
type State =
| { status: "idle" }
| { status: "loading"; requestId: string }
| { status: "success"; data: string }
| { status: "error"; message: string }
Setiap varian membawa data yang tepat relevan untuk state tersebut. Tidak ada field data opsional di idle, tidak ada field message di success. Bentuk objek memberitahu Anda state mana yang sedang aktif, dan compiler secara otomatis menyempitkan type di dalam switch.
function handleState(state: State): string {
switch (state.status) {
case "idle":
return "Waiting..."
case "loading":
return `Loading ${state.requestId}`
case "success":
return state.data
case "error":
return state.message
}
}
Di dalam case "loading", typenya adalah { status: "loading"; requestId: string }, jadi state.requestId tersedia. Compiler menyempitkan secara otomatis karena literal status berbeda untuk setiap varian.
Ini mengungguli string enums dan boolean flag soup. Tapi ini belum exhaustive. Hapus case error dan TypeScript tetap berhasil dikompilasi. Fungsi secara implisit mengembalikan undefined, dan compiler tetap diam.
Trik never yang memaksa penanganan exhaustive
Berikut helpernya:
function assertNever(value: never): never {
throw new Error(`Unhandled value: ${JSON.stringify(value)}`)
}
Fungsi ini hanya menerima never, union kosong, type yang tidak memiliki nilai. Tidak mungkin memanggil assertNever dengan nilai nyata kecuali Anda telah berbohong kepada compiler.
Sekarang tambahkan di bagian bawah switch Anda:
function handleState(state: State): string {
switch (state.status) {
case "idle":
return "Waiting..."
case "loading":
return `Loading ${state.requestId}`
case "success":
return state.data
case "error":
return state.message
default:
return assertNever(state)
}
}
Jika setiap varian yang mungkin dari State ditangani di atas default, maka state di dalam cabang default telah disempitkan menjadi never. Pemanggilan assertNever(state) berhasil dikompilasi.
Jika Anda menambahkan state baru dan lupa sebuah case, cabang default sekarang menerima type nyata. Anda tidak bisa melewatkan type nyata ke parameter never. Compiler melemparkan type error.
Melihatnya gagal dalam praktik
Tambahkan state retrying:
type State =
| { status: "idle" }
| { status: "loading"; requestId: string }
| { status: "success"; data: string }
| { status: "error"; message: string }
| { status: "retrying"; attempt: number; lastError: string }
Sekarang handleState gagal dikompilasi:
Argument of type '{ status: "retrying"; attempt: number; lastError: string; }'
is not assignable to parameter of type 'never'.
Error menunjuk langsung ke cabang default. Bukan runtime crash. Penolakan compile-time untuk membangun sampai Anda memutuskan apa artinya retrying dalam fungsi spesifik ini.
Setiap fungsi yang melakukan switch pada State mendapatkan errornya sendiri. Compiler tidak membiarkan Anda menunda pekerjaan.
Pola ini diskalakan ke union type apa pun
Ini tidak spesifik untuk state machine. Discriminated union apa pun mendapat manfaat dari perlakuan yang sama.
Event handlers:
type Event =
| { type: "USER_LOGIN"; userId: string }
| { type: "USER_LOGOUT" }
| { type: "PAGE_VIEW"; path: string }
function handleEvent(event: Event): void {
switch (event.type) {
case "USER_LOGIN":
trackLogin(event.userId)
return
case "USER_LOGOUT":
trackLogout()
return
case "PAGE_VIEW":
trackPageView(event.path)
return
default:
assertNever(event)
}
}
Reducer bergaya Redux:
type Action =
| { type: "increment"; amount: number }
| { type: "decrement"; amount: number }
| { type: "reset" }
function reducer(state: number, action: Action): number {
switch (action.type) {
case "increment":
return state + action.amount
case "decrement":
return state - action.amount
case "reset":
return 0
default:
return assertNever(action)
}
}
Union dari objek dengan discriminant literal. Switch di atasnya. Default ke assertNever. Tambahkan varian, dan setiap switch meledak sampai Anda menanganinya.
Di mana ini gagal dan apa yang harus dilakukan
Exhaustiveness checking bukanlah sihir. Kenali sisi tajamnya sebelum Anda mengadopsinya di mana-mana.
Anda harus menggunakan discriminated union
Ini tidak bekerja dengan string enums biasa atau boolean flags. Compiler memerlukan properti literal bersama untuk menyempitkan.
// This does NOT work for exhaustiveness checking
enum Status {
Idle = "idle",
Loading = "loading",
Success = "success",
}
assertNever hanya membuktikan exhaustiveness pada closed unions. Dengan enums, compiler mengasumsikan string yang cocok valid. Ia tidak bisa membuktikan Anda melewatkan sebuah case.
Anda tidak boleh menggunakan any atau type assertions
Jika Anda melakukan cast melalui as State atau mengambil dari response yang bertipe any, compiler melewatkan narrowing. Validasi di edges, lalu percayai type di dalamnya.
assertNever melempar pada runtime sebagai last resort
Fungsi ini adalah compile-time guard dulu, runtime guard kedua. Secara teori Anda tidak pernah mencapai throw. Secara praktik, sebuah cast atau JSON yang stale membuatnya menjadi safety net Anda. Itu lebih baik daripada diam-diam mengembalikan undefined.
Ini menjadi berisik dengan banyak cabang
Menambahkan case kesebelas memicu error di setiap fungsi yang melakukan switch pada type tersebut. Itulah tujuannya. Tapi jika union Anda tumbuh melewati enam atau tujuh varian, pertimbangkan untuk membaginya menjadi sub-unions yang lebih kecil atau mesin terpisah.
Cara memperkenalkan ini tanpa menulis ulang semuanya
Mulai dengan state machine yang menyebabkan bug paling banyak.
- Temukan field
statusyang bertipestringatau enum yang lebar. Ganti dengan closed union. - Tambahkan
assertNeverke fungsi yang melakukan switch di atasnya. - Biarkan compiler membimbing Anda.
- Tambahkan checklist code review yang mengharuskan
assertNeverdi reducer baru.
Bahkan dengan XState, Anda masih akan menulis mapper dan selector yang melakukan switch pada nilai state. Fungsi-fungsi tersebut masih memerlukan exhaustiveness checking.
FAQ
Apakah ini bekerja dengan if/else alih-alih switch?
Ya, tapi ini rapuh. Anda memerlukan else terakhir yang memanggil assertNever. Mengubah urutan kondisi atau menambahkan early return, dan Anda bisa secara tidak sengaja melewatkan check. switch lebih aman karena cabang default sudah jelas.
Bagaimana jika saya benar-benar tidak peduli dengan beberapa state dalam fungsi spesifik?
Tangani secara eksplisit. Jangan biarkan mereka jatuh ke assertNever secara tidak sengaja.
case "retrying":
// No-op in this context
return ""
Compiler memaksa Anda untuk membuat keputusan tersebut terlihat. Itulah tujuannya.
Bisakah saya mengembalikan nilai dari assertNever?
Fungsi yang mengembalikan never dapat di-assign ke return type apa pun, karena ia tidak pernah benar-benar return. Jika Anda ingin fallback alih-alih melempar:
function assertNever(value: never, fallback: string): string {
console.error("Unhandled state:", value)
return fallback
}
Parameter value: never menjaga compile-time check tetap utuh.
Apakah ini bekerja di JavaScript?
Tidak. Ini adalah fitur compile-time TypeScript. Pada runtime, assertNever hanyalah fungsi yang melempar. Keamanannya berasal dari type checker yang menolak untuk membangun kode yang bisa mencapainya.
Jadikan compiler reviewer kedua Anda
Setiap kali Anda menambahkan state, Anda menciptakan pekerjaan di reducer, UI, analytics pipeline, logika export. Hasil defaultnya adalah Anda melewatkan salah satu dari tempat-tempat tersebut, ship, dan menemukannya nanti.
Exhaustiveness checking membalikkan default tersebut. Compiler menjadi reviewer yang tidak pernah lupa untuk memeriksa switch statements. Ia tidak menggantikan test, tapi ia menangkap kategori bug yang jarang dicakup oleh test: yang mana Anda sama sekali tidak tahu bahwa Anda harus menulis kode.
Tambahkan helpernya. Gunakan di satu reducer. Kali berikutnya Anda memperluas state machine, biarkan TypeScript memberitahu Anda di mana pekerjaannya. Ia tidak akan melewatkan sebuah cabang.