Panduan komprehensif tentang tipe 'never'. Pelajari cara memanfaatkan pemeriksaan ekstensif untuk kode yang kuat dan bebas bug, serta pahami hubungannya dengan penanganan kesalahan tradisional.
Tipe Never: Peralihan dari Kesalahan Runtime ke Jaminan Waktu Kompilasi
Dalam dunia pengembangan perangkat lunak, kita menghabiskan banyak waktu dan upaya untuk mencegah, menemukan, dan memperbaiki bug. Beberapa bug yang paling berbahaya adalah bug yang muncul secara diam-diam. Mereka tidak langsung merusak aplikasi; alih-alih, mereka bersembunyi dalam kasus tepi yang tidak tertangani, menunggu bagian data tertentu atau tindakan pengguna untuk memicu perilaku yang salah. Sumber umum dari bug semacam itu adalah kelalaian sederhana: seorang pengembang menambahkan opsi baru ke serangkaian pilihan tetapi lupa untuk memperbarui semua tempat dalam kode yang perlu menanganinya.
Pertimbangkan pernyataan `switch` yang memproses berbagai jenis notifikasi pengguna. Ketika jenis notifikasi baru, katakanlah 'POLL_RESULT', ditambahkan, apa yang terjadi jika kita lupa menambahkan blok `case` yang sesuai dalam fungsi rendering notifikasi kita? Dalam banyak bahasa, kode hanya akan jatuh, tidak melakukan apa-apa, dan gagal secara diam-diam. Pengguna tidak pernah melihat hasil polling, dan kita mungkin tidak menemukan bug selama berminggu-minggu.
Bagaimana jika kompiler dapat mencegah ini? Bagaimana jika alat kita sendiri dapat memaksa kita untuk menangani setiap kemungkinan, mengubah potensi kesalahan logika runtime menjadi kesalahan tipe waktu kompilasi? Inilah tepatnya kekuatan yang ditawarkan oleh tipe 'never', sebuah konsep yang ditemukan dalam bahasa-bahasa bertipe statis modern. Ini adalah mekanisme untuk memberlakukan pemeriksaan ekstensif, memberikan jaminan waktu kompilasi yang kuat bahwa semua kasus ditangani. Artikel ini membahas tipe `never`, membandingkan perannya dengan penanganan kesalahan tradisional, dan menunjukkan cara menggunakannya untuk membangun sistem perangkat lunak yang lebih tangguh dan mudah dipelihara.
Apa Sebenarnya Tipe 'Never'?
Pada pandangan pertama, tipe `never` mungkin tampak esoteris atau murni akademis. Namun, implikasi praktisnya sangat besar. Untuk memahaminya, kita perlu memahami dua karakteristik utamanya.
Tipe untuk Hal yang Mustahil
Tipe `never` mewakili nilai yang tidak pernah dapat terjadi. Ini adalah tipe yang tidak berisi nilai yang mungkin. Ini terdengar abstrak, tetapi digunakan untuk menandakan dua skenario utama:
- Fungsi yang tidak pernah kembali: Ini tidak berarti fungsi yang tidak mengembalikan apa pun (itu `void`). Ini berarti fungsi yang tidak pernah mencapai titik akhirnya. Ini mungkin memunculkan kesalahan, atau mungkin memasuki loop tak terbatas. Kuncinya adalah bahwa alur eksekusi normal terganggu secara permanen.
- Variabel dalam keadaan yang mustahil: Melalui deduksi logis (proses yang disebut penyempitan tipe), kompiler dapat menentukan bahwa variabel tidak mungkin menyimpan nilai apa pun dalam blok kode tertentu. Dalam situasi ini, tipe variabel secara efektif adalah `never`.
Dalam teori tipe, `never` dikenal sebagai tipe bottom (sering dilambangkan dengan ⊥). Menjadi tipe bottom berarti itu adalah subtipe dari setiap tipe lainnya. Ini masuk akal: karena nilai dari tipe `never` tidak pernah dapat ada, itu dapat ditetapkan ke variabel dari tipe `string`, `number`, atau `User` tanpa melanggar keamanan tipe, karena baris kode itu terbukti tidak dapat dijangkau.
Perbedaan Krusial: `never` vs. `void`
Titik kebingungan umum adalah perbedaan antara `never` dan `void`. Perbedaannya sangat penting:
void: Mewakili tidak adanya nilai kembalian yang dapat digunakan. Fungsi berjalan hingga selesai dan kembali, tetapi nilai kembaliannya tidak dimaksudkan untuk digunakan. Pikirkan tentang fungsi yang hanya mencatat ke konsol.never: Mewakili ketidakmungkinan untuk kembali. Fungsi menjamin bahwa ia tidak akan menyelesaikan jalur eksekusinya secara normal.
Mari kita lihat contoh TypeScript:
// Fungsi ini mengembalikan 'void'. Ini selesai dengan sukses.
function logMessage(message: string): void {
console.log(message);
// Secara implisit mengembalikan 'undefined'
}
// Fungsi ini mengembalikan 'never'. Ini tidak pernah selesai.
function throwError(message: string): never {
throw new Error(message);
}
// Fungsi ini juga mengembalikan 'never' karena loop tak terbatas.
function processTasks(): never {
while (true) {
// ... memproses tugas dari antrian
}
}
Memahami perbedaan ini adalah langkah pertama untuk membuka kekuatan praktis dari `never`.
Kasus Penggunaan Inti: Pemeriksaan Ekstensif
Aplikasi yang paling berdampak dari tipe `never` adalah untuk memberlakukan pemeriksaan ekstensif pada waktu kompilasi. Hal ini memungkinkan kita untuk membangun jaring pengaman yang memastikan bahwa kita telah menangani setiap varian dari tipe data tertentu.Masalah: Pernyataan `switch` yang Rapuh
Mari kita modelkan serangkaian bentuk geometris menggunakan discriminated union. Ini adalah pola yang kuat di mana Anda memiliki properti umum (the 'discriminant', seperti `kind`) yang memberi tahu Anda varian tipe mana yang Anda hadapi.
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; sideLength: number };
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.sideLength ** 2;
}
// Apa yang terjadi jika kita mendapatkan bentuk yang tidak kita kenali?
// Fungsi ini secara implisit akan mengembalikan 'undefined', sebuah bug yang mungkin terjadi!
}
Kode ini berfungsi untuk saat ini. Tetapi apa yang terjadi ketika aplikasi kita berkembang? Seorang kolega menambahkan bentuk baru:
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; sideLength: number }
| { kind: 'rectangle'; width: number; height: number }; // Bentuk baru ditambahkan!
Fungsi `getArea` sekarang tidak lengkap. Jika menerima `rectangle`, pernyataan `switch` tidak akan memiliki kasus yang cocok, fungsi akan selesai, dan dalam JavaScript/TypeScript, ia akan mengembalikan `undefined`. Kode panggilan mengharapkan `number` tetapi mendapat `undefined`, yang mengarah ke kesalahan `NaN` atau bug halus lainnya jauh ke hilir. Kompiler tidak memberi kita peringatan apa pun.
Solusi: Tipe `never` sebagai Pengaman
Kita dapat memperbaiki ini dengan menggunakan tipe `never` dalam kasus `default` dari pernyataan `switch` kita. Penambahan sederhana ini mengubah kompiler menjadi mitra kita yang waspada.
function getAreaWithExhaustiveCheck(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.sideLength ** 2;
// Bagaimana dengan 'rectangle'? Kami melupakannya.
default:
// Di sinilah keajaiban terjadi.
const _exhaustiveCheck: never = shape;
// Baris di atas sekarang akan menyebabkan kesalahan waktu kompilasi!
// Tipe 'Rectangle' tidak dapat ditetapkan ke tipe 'never'.
return _exhaustiveCheck;
}
}
Mari kita uraikan mengapa ini berfungsi:
- Penyempitan Tipe: Di dalam setiap blok `case`, kompiler TypeScript cukup pintar untuk mempersempit tipe variabel `shape`. Dalam `case 'circle'`, kompiler tahu `shape` adalah `{ kind: 'circle'; radius: number }`.
- Blok `default`: Ketika kode mencapai blok `default`, kompiler menyimpulkan tipe apa yang mungkin dimiliki `shape`. Ia mengurangi semua kasus yang ditangani dari union `Shape` asli.
- Skenario Kesalahan: Dalam contoh yang diperbarui, kita menangani `'circle'` dan `'square'`. Oleh karena itu, di dalam blok `default`, kompiler tahu `shape` harus `{ kind: 'rectangle'; ... }`. Kode kita kemudian mencoba menetapkan objek `rectangle` ini ke variabel `_exhaustiveCheck`, yang memiliki tipe `never`. Penugasan ini gagal dengan kesalahan tipe yang jelas: `Tipe 'Rectangle' tidak dapat ditetapkan ke tipe 'never'`. Bug tertangkap sebelum kode dijalankan!
- Skenario Sukses: Jika kita menambahkan `case` untuk `'rectangle'`, maka di blok `default`, kompiler akan menghabiskan semua kemungkinan. Tipe `shape` akan dipersempit menjadi `never` (tidak mungkin lingkaran, persegi, atau persegi panjang, jadi itu adalah tipe yang mustahil). Menetapkan nilai dari tipe `never` ke variabel dari tipe `never` sangat valid. Kode dikompilasi tanpa kesalahan.
Pola ini, sering disebut "trik exhaustiveness," secara efektif mendelegasikan kompiler untuk memberlakukan kelengkapan. Ini mengubah konvensi runtime yang rapuh menjadi jaminan waktu kompilasi yang kokoh.
Pemeriksaan Ekstensif vs. Penanganan Kesalahan Tradisional
Sangat menggoda untuk menganggap pemeriksaan ekstensif sebagai pengganti penanganan kesalahan, tetapi itu adalah kesalahpahaman. Mereka adalah alat pelengkap yang dirancang untuk menyelesaikan kelas masalah yang berbeda. Perbedaan utama terletak pada apa yang mereka rancang untuk ditangani: keadaan yang dapat diprediksi dan diketahui versus peristiwa luar biasa yang tidak dapat diprediksi.
Mendefinisikan Konsep
-
Penanganan Kesalahan adalah strategi runtime untuk mengelola situasi luar biasa dan tidak terduga yang seringkali berada di luar kendali program. Ini berkaitan dengan kegagalan yang dapat dan memang terjadi selama eksekusi.
- Contoh: Permintaan jaringan gagal, file tidak ditemukan di disk, input pengguna tidak valid, koneksi database kehabisan waktu.
- Alat: blok `try...catch`, `Promise.reject()`, mengembalikan kode kesalahan atau `null`, tipe `Result` (seperti yang terlihat dalam bahasa seperti Rust).
-
Pemeriksaan Ekstensif adalah strategi waktu kompilasi untuk memastikan bahwa semua jalur logis atau keadaan data yang diketahui dan valid ditangani secara eksplisit dalam logika program. Ini tentang memastikan kode Anda lengkap.
- Contoh: Menangani semua varian dari enum, memproses semua tipe dalam discriminated union, mengelola semua keadaan dari mesin status terbatas.
- Alat: Tipe `never`, `switch` atau `match` exhaustiveness yang diberlakukan bahasa (seperti yang terlihat di Swift dan Rust).
Prinsip Panduan: Diketahui vs. Tidak Diketahui
Cara sederhana untuk memutuskan pendekatan mana yang akan digunakan adalah dengan bertanya pada diri sendiri tentang sifat masalahnya:
- Apakah ini serangkaian kemungkinan yang telah saya definisikan dan kendalikan dalam basis kode saya? Gunakan pemeriksaan ekstensif. Ini adalah "yang diketahui" Anda. Union `Shape` Anda adalah contoh sempurna; Anda mendefinisikan semua kemungkinan bentuk.
- Apakah ini peristiwa yang berasal dari sistem eksternal, pengguna, atau lingkungan, di mana kegagalan mungkin terjadi dan input yang tepat tidak dapat diprediksi? Gunakan penanganan kesalahan. Ini adalah "yang tidak diketahui" Anda. Anda tidak dapat menggunakan sistem tipe untuk membuktikan bahwa jaringan akan selalu tersedia.
Analisis Skenario: Kapan Menggunakan Yang Mana
Skenario 1: Parsing Respons API (Penanganan Kesalahan)
Bayangkan Anda mengambil data pengguna dari API pihak ketiga. Dokumentasi API mengatakan itu akan mengembalikan objek JSON dengan bidang `status`. Anda tidak dapat mempercayai ini pada waktu kompilasi. Jaringan mungkin mati, API mungkin usang dan mengembalikan kesalahan 500, atau mungkin mengembalikan string JSON yang salah format. Ini adalah domain penanganan kesalahan.
async function fetchUser(userId: string): Promise<User> {
try {
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) {
// Tangani kesalahan HTTP (mis., 404, 500)
throw new Error(`Kesalahan API: ${response.status}`);
}
const data = await response.json();
// Di sini Anda juga akan menambahkan validasi runtime dari struktur data
return data as User;
} catch (error) {
// Tangani kesalahan jaringan, kesalahan parsing JSON, dll.
console.error("Gagal mengambil pengguna:", error);
throw error; // Lempar ulang atau tangani dengan baik
}
}
Menggunakan `never` di sini tidak tepat karena kemungkinan kegagalan tidak terbatas dan eksternal untuk sistem tipe kita.
Skenario 2: Rendering Status Komponen UI (Pemeriksaan Ekstensif)
Sekarang, katakanlah komponen UI Anda dapat berada dalam salah satu dari beberapa keadaan yang terdefinisi dengan baik. Anda mengendalikan keadaan ini sepenuhnya dalam kode aplikasi Anda. Ini adalah kandidat yang sempurna untuk discriminated union dan pemeriksaan ekstensif.
type ComponentState =
| { status: 'loading' }
| { status: 'success'; data: string[] }
| { status: 'error'; message: string };
function renderComponent(state: ComponentState): string { // Mengembalikan string HTML
switch (state.status) {
case 'loading':
return `<div>Memuat...</div>`;
case 'success':
return `<ul>${state.data.map(item => `<li>${item}</li>`).join('')}</ul>`;
case 'error':
return `<div class="error">Error: ${state.message}</div>`;
default:
// Jika kita kemudian menambahkan status 'submitting', baris ini akan melindungi kita!
const _exhaustiveCheck: never = state;
throw new Error(`Keadaan yang tidak ditangani: ${_exhaustiveCheck}`);
}
}
Jika seorang pengembang menambahkan keadaan baru, `{ status: 'idle' }`, kompiler akan segera menandai `renderComponent` sebagai tidak lengkap, mencegah bug UI di mana komponen dirender sebagai ruang kosong.
Sinergi: Menggabungkan Kedua Pendekatan untuk Sistem yang Tangguh
Sistem yang paling tangguh tidak memilih satu di atas yang lain; mereka menggunakan keduanya secara bersamaan. Penanganan kesalahan mengelola dunia eksternal yang kacau, sementara pemeriksaan ekstensif memastikan logika internal sehat dan lengkap. Output dari batas penanganan kesalahan sering menjadi input untuk sistem yang bergantung pada pemeriksaan ekstensif.
Mari kita perbaiki contoh pengambilan API kita. Fungsi dapat menangani kesalahan jaringan yang tidak dapat diprediksi, tetapi begitu berhasil atau gagal dengan cara yang terkontrol, ia mengembalikan hasil yang dapat diprediksi dan bertipe baik yang dapat diproses oleh seluruh aplikasi kita dengan percaya diri.
// 1. Definisikan hasil yang dapat diprediksi dan bertipe baik untuk logika internal kita.
type FetchResult<T> =
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
// 2. Fungsi sekarang menggunakan Penanganan Kesalahan untuk menghasilkan hasil yang dapat Diperiksa Secara Ekstensif.
async function fetchUserData(userId: string): Promise<FetchResult<User>> {
try {
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) {
throw new Error(`API mengembalikan status ${response.status}`);
}
const data = await response.json();
// Tambahkan validasi runtime di sini (mis., dengan Zod atau io-ts)
return { status: 'success', data: data as User };
} catch (error) {
// Kami menangkap SETIAP potensi kesalahan dan membungkusnya dalam struktur yang kita ketahui.
return { status: 'error', error: error instanceof Error ? error : new Error('Terjadi kesalahan yang tidak diketahui') };
}
}
// 3. Kode panggilan sekarang dapat menggunakan Pemeriksaan Ekstensif untuk logika yang bersih dan aman.
async function displayUser(userId: string) {
const result = await fetchUserData(userId);
switch (result.status) {
case 'success':
console.log(`Nama pengguna: ${result.data.name}`);
break;
case 'error':
console.error(`Gagal menampilkan pengguna: ${result.error.message}`);
break;
default:
const _exhaustiveCheck: never = result;
// Ini memastikan jika kita menambahkan status 'loading' ke FetchResult,
// blok kode ini akan gagal dikompilasi sampai kita menanganinya.
return _exhaustiveCheck;
}
}
Pola gabungan ini sangat kuat. Fungsi `fetchUserData` bertindak sebagai batas, menerjemahkan dunia permintaan jaringan yang tidak dapat diprediksi ke dalam union yang dapat didiskriminasi dan dapat diprediksi. Sisa aplikasi kemudian dapat beroperasi pada struktur data yang bersih ini dengan jaring pengaman penuh dari pemeriksaan kelengkapan waktu kompilasi.
Perspektif Global: `never` dalam Bahasa Lain
Konsep tipe bottom dan kelengkapan waktu kompilasi tidak unik untuk TypeScript. Ini adalah ciri khas dari banyak bahasa modern yang berfokus pada keselamatan. Melihat bagaimana itu diimplementasikan di tempat lain memperkuat kepentingan mendasarnya dalam rekayasa perangkat lunak.
- Rust: Rust memiliki tipe `!`, disebut "tipe never". Ini adalah tipe kembalian fungsi yang "menyimpang," seperti makro `panic!()`, yang mengakhiri thread eksekusi saat ini. Ekspresi `match` Rust yang kuat (versinya `switch`) memberlakukan exhaustiveness secara default. Jika Anda `match` pada `enum` dan gagal untuk mencakup semua varian, kode tidak akan dikompilasi. Anda tidak memerlukan trik `never` manual karena bahasa menyediakan keamanan ini di luar kotak.
- Swift: Swift memiliki enum kosong yang disebut `Never`. Ini digunakan untuk menunjukkan bahwa fungsi atau metode tidak akan pernah kembali, baik dengan memunculkan kesalahan atau dengan tidak mengakhiri. Seperti Rust, pernyataan `switch` Swift harus ekstensif secara default, memberikan keamanan waktu kompilasi saat bekerja dengan enum.
- Kotlin: Kotlin memiliki tipe `Nothing`, yang merupakan tipe bottom dari sistem tipenya. Ini digunakan untuk menunjukkan bahwa fungsi tidak pernah kembali, seperti fungsi `TODO()` dari pustaka standar, yang selalu memunculkan kesalahan. Ekspresi `when` Kotlin (ekuivalen `switch` -nya) juga dapat digunakan untuk pemeriksaan ekstensif, dan kompiler akan mengeluarkan peringatan atau kesalahan jika tidak ekstensif ketika digunakan sebagai ekspresi.
- Python (dengan Petunjuk Tipe): Modul `typing` Python menyertakan `NoReturn`, yang dapat digunakan untuk menganotasi fungsi yang tidak pernah kembali. Sementara sistem tipe Python bertahap dan tidak seketat Rust atau Swift, anotasi ini memberikan informasi berharga untuk alat analisis statis seperti Mypy, yang kemudian dapat melakukan pemeriksaan yang lebih menyeluruh.
Benang merah di seluruh ekosistem yang beragam ini adalah pengakuan bahwa membuat keadaan yang mustahil tidak dapat direpresentasikan pada tingkat tipe adalah cara yang ampuh untuk menghilangkan seluruh kelas bug.
Wawasan yang Dapat Ditindaklanjuti dan Praktik Terbaik
Untuk mengintegrasikan konsep yang kuat ini ke dalam pekerjaan harian Anda, pertimbangkan praktik berikut:
- Rangkul Discriminated Unions: Secara aktif modelkan data Anda dengan discriminated unions (juga disebut tagged unions atau sum types) setiap kali Anda memiliki tipe yang dapat menjadi salah satu dari beberapa varian yang berbeda. Ini adalah fondasi di mana pemeriksaan ekstensif dibangun. Modelkan hasil API, keadaan komponen, dan peristiwa dengan cara ini.
- Buat Keadaan Ilegal Tidak Dapat Direpresentasikan: Ini adalah prinsip inti dari desain berbasis tipe. Jika seorang pengguna tidak dapat menjadi admin dan tamu pada saat yang sama, sistem tipe Anda harus mencerminkan hal itu. Gunakan unions (`A | B`) alih-alih beberapa bendera boolean opsional (`isAdmin?: boolean; isGuest?: boolean;`). Tipe `never` adalah alat utama untuk membuktikan bahwa suatu keadaan tidak dapat direpresentasikan.
-
Buat Fungsi Pembantu yang Dapat Digunakan Kembali: Kasus `default` dapat dibuat lebih bersih dengan fungsi pembantu sederhana. Ini juga memberikan kesalahan yang lebih deskriptif jika kode pernah dicapai pada waktu runtime (yang seharusnya tidak mungkin).
function assertNever(value: never): never { throw new Error(`Anggota union yang didiskriminasi yang tidak ditangani: ${JSON.stringify(value)}`); } // Penggunaan: default: assertNever(shape); // Lebih bersih dan memberikan pesan kesalahan runtime yang lebih baik. - Dengarkan Kompiler Anda: Perlakukan kesalahan exhaustiveness bukan sebagai gangguan, tetapi sebagai hadiah. Kompiler bertindak sebagai peninjau kode otomatis yang rajin yang telah menemukan kesalahan logis dalam program Anda. Beri tahu, dan perbaiki kode.
Kesimpulan: Penjaga Senyap Basis Kode Anda
Tipe `never` jauh lebih dari sekadar keingintahuan teoretis; ini adalah alat pragmatis dan kuat untuk membangun perangkat lunak yang tangguh, mendokumentasikan diri sendiri, dan mudah dipelihara. Dengan memanfaatkannya untuk pemeriksaan ekstensif, kita secara fundamental mengubah cara kita mendekati kebenaran. Kita mengalihkan beban memastikan kelengkapan logis dari memori manusia yang mudah salah dan pengujian runtime ke dunia analisis tipe waktu kompilasi yang sempurna dan otomatis.
Sementara penanganan kesalahan tradisional tetap penting untuk mengelola sifat sistem eksternal yang tidak dapat diprediksi, pemeriksaan ekstensif memberikan jaminan pelengkap untuk logika internal yang diketahui dari aplikasi kita. Bersama-sama, mereka membentuk pertahanan berlapis terhadap bug, menciptakan sistem yang tidak hanya kurang rentan terhadap kegagalan tetapi juga lebih mudah untuk dipahami dan lebih aman untuk difaktorkan ulang.
Lain kali Anda menemukan diri Anda menulis pernyataan `switch` atau rantai `if-else-if` yang panjang di atas serangkaian kemungkinan yang diketahui, berhenti sejenak dan tanyakan: dapatkah tipe `never` berfungsi sebagai penjaga senyap untuk kode ini? Dengan melakukan itu, Anda akan menulis kode yang tidak hanya benar hari ini tetapi juga diperkuat terhadap pengawasan di masa mendatang.