Tanda tangan fungsi Anda mengatakan bahwa fungsi tersebut mengembalikan User. Tidak. Fungsi tersebut mengembalikan User atau meledak. Sistem tipe sama sekali tidak mengetahui tentang cabang kedua tersebut.

Inilah ketidakjujuran fundamental dari penanganan error berbasis exception. Setiap throw adalah jalur alur kontrol yang tidak dapat dilihat, diperiksa, dan diberlakukan oleh compiler Anda. Anda akhirnya memiliki kode yang lolos type-check dengan sempurna namun tetap crash di produksi karena seseorang lupa menulis try/catch tiga lapisan pemanggilan di atasnya.

Pengembalian error secara eksplisit memperbaiki ini. Mereka menjadikan kegagalan sebagai warga kelas satu dalam sistem tipe Anda. Pertanyaannya adalah apakah biaya ergonomi sepadan dengan kejujuran tersebut.

Apa yang Dimaksud dengan Kebenaran Level Tipe untuk Error

Kebenaran level tipe berarti tipe Anda mengatakan yang sebenarnya tentang apa yang dapat dihasilkan oleh sebuah fungsi. Jika sebuah fungsi dapat gagal, tipe pengembalian harus mengatakannya. Jika tidak, tipe pengembalian harus menjamin keberhasilan.

Rust mengenkode ini dengan Result<T, E>:

fn parse_port(s: &str) -> Result<u16, ParseIntError> {
    s.parse()
}

Tanda tangannya lengkap. Pemanggil tahu ada dua hasil. Compiler memaksa mereka untuk menangani keduanya.

TypeScript, secara default, tidak melakukannya. Fungsi yang melempar terlihat identik dengan yang tidak melempar:

function parsePort(s: string): number {
  const n = parseInt(s, 10);
  if (isNaN(n) || n < 0 || n > 65535) {
    throw new Error(`Invalid port: ${s}`);
  }
  return n;
}

Tipe pengembaliannya mengatakan number. Seharusnya mengatakan number | Error, atau lebih akurat, Result<number, Error>. Tapi tidak, jadi pemanggil tidak mendapat sinyal bahwa mereka perlu bertahan terhadap kegagalan.

Bagaimana Error yang Dilempar Bersembunyi di Graf Panggilan Anda

Kerusakannya bertambah parah saat call stack Anda semakin dalam. Error yang dilempar di fungsi daun memaksa setiap lapisan perantara untuk menangkapnya atau menyebarkannya secara implisit. Tidak ada jalur propagasi tersebut yang muncul di tanda tangan tipe apa pun.

Pertimbangkan sebuah config loader yang memanggil parser yang memanggil validator. Jika validator melempar, parser dan loader keduanya juga menjadi dapat melempar. Tapi tipe mereka tidak berubah. Anda tidak bisa melihat loadConfig() dan tahu bahwa fungsi tersebut mungkin gagal. Anda harus membaca setiap baris setiap dependency, atau menunggu crash di runtime.

Ini adalah kebalikan persis dari bagaimana tipe seharusnya bekerja. Tipe ada sehingga Anda tidak perlu membaca implementasi untuk memahami kontrak.

Pengembalian error secara eksplisit membalikkan ini. Jalur kegagalan terlihat di setiap lapisan:

type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };

function parsePort(s: string): Result<number, string> {
  const n = parseInt(s, 10);
  if (isNaN(n) || n < 0 || n > 65535) {
    return { ok: false, error: `Invalid port: ${s}` };
  }
  return { ok: true, value: n };
}

function loadConfig(raw: Record<string, string>): Result<Config, string> {
  const portResult = parsePort(raw.port);
  if (!portResult.ok) return portResult;

  const host = raw.host || "localhost";

  return { ok: true, value: { port: portResult.value, host } };
}

Sekarang tanda tangan loadConfig mengatakan seluruh cerita. Anda tahu fungsi tersebut dapat gagal sebelum membaca satu baris pun dari badannya.

Pertukaran Sebenarnya Adalah Ergonomi, Bukan Ekspresivitas

Pendekatan yang jujur lebih bertele-tele. Tidak ada yang suka menulis { ok: false, error: ... } alih-alih throw new Error(...). Kebisingan visual bertambah, terutama saat Anda merangkai beberapa operasi yang dapat gagal.

Inilah mengapa sebagian besar bahasa dengan checked exception meninggalkannya. Checked exception Java mencoba memberlakukan penanganan eksplisit di level tipe, tapi sintaksnya sangat menghukum. Operator ? Rust dan kombinator Result seperti map dan and_then memulihkan sebagian besar ergonomi tersebut. TypeScript tidak memiliki operator ?, tapi Anda bisa mendekatinya dengan fungsi pembantu:

function map<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
  return result.ok ? { ok: true, value: fn(result.value) } : result;
}

function flatMap<T, U, E>(
  result: Result<T, E>,
  fn: (value: T) => Result<U, E>
): Result<U, E> {
  return result.ok ? fn(result.value) : result;
}

Ini masih lebih canggung daripada Rust. Jika Anda menulis TypeScript, library seperti neverthrow menyediakan tipe Result dengan metode yang dapat dirantai yang terasa hampir native. Overhead-nya nyata, tapi lebih kecil daripada yang terlihat setelah Anda memiliki pembantu yang tersedia.

Ada biaya lain. Stack trace dari error yang dilempar kaya dan otomatis. Error yang dikembalikan hanyalah nilai. Jika Anda ingin stack trace, Anda harus membuatnya sendiri. Untuk debugging, ini penting. Untuk penanganan error yang dapat diprediksi dalam logika bisnis, biasanya tidak.

Kapan Melempar Masih Masuk Akal

Kami tidak menganjurkan untuk tidak menggunakan exception sama sekali. Beberapa kegagalan benar-benar exceptional dan tidak seharusnya menjadi bagian dari model error normal Anda. File yang hilang saat memuat config adalah kegagalan yang diharapkan. Error kehabisan memori bukan.

Aturan praktis: jika kegagalan adalah bagian dari domain Anda, kembalikan. Jika itu bug pemrograman atau kegagalan sistem yang kataklysmik, lemparkan. JSON.parse melempar pada input yang malformed menjengkelkan karena JSON tidak valid adalah hal yang umum. Array.prototype.map melempar pada array null tidak apa-apa karena memanggil .map pada null adalah bug.

Dalam praktiknya, ini berarti batasan I/O, parser, validator, dan aturan bisnis Anda harus mengembalikan error. Invarian, asersi, dan kondisi yang benar-benar kataklysmik masih dapat dilempar.

Mulai dari Batasan API Anda

Anda tidak perlu me-refactor seluruh basis kode Anda. Tempat dengan nilai tertinggi untuk mengadopsi pengembalian error eksplisit adalah API publik dan lapisan I/O Anda. Ini adalah batasan di mana pemanggil perlu tahu apa yang bisa salah.

Pilih satu module. Ubah tipe pengembaliannya menjadi Result<T, E>. Perbarui pemanggilnya. Lihat bagaimana rasanya. Pola ini membuat ketagihan dengan cara tertentu: setelah Anda berhenti mencari-cari stack trace untuk blok try/catch yang hilang, Anda menyadari berapa banyak overhead mental yang dihabiskan oleh thrown exception.

Kebenaran level tipe untuk error bukan tentang menulis kode yang sempurna. Ini tentang menulis kode yang lebih sedikit berbohong.