Section Penanganan Error mengajari kita membangun enum error sendiri lengkap dengan implementasi Display dan konversi From — pola yang benar, tetapi menulis semuanya manual terasa berulang kali lebih panjang daripada logika bisnisnya. Kebutuhan error juga berbeda menurut posisi kode: aplikasi menginginkan pesan yang kaya konteks dan mudah mengalir ke atas, sedangkan library wajib mengekspos tipe error yang bisa di-match oleh pemanggilnya. Dua crate paling populer di ekosistem membagi tugas tersebut dengan rapi: anyhow menyederhanakan error di sisi aplikasi, thiserror menyederhanakan pembuatan tipe error di sisi library. Pada Chapter ini kita memakai keduanya dalam proyek sungguhan dan melihat bagaimana keduanya saling melengkapi, bukan bersaing.

Dua Peran yang Berbeda #

Sebelum menulis kode, mari kita tegaskan pembagian perannya, karena memilih crate yang salah untuk peran yang salah adalah kesalahan paling umum:

Aspekanyhowthiserror
SasaranAplikasi (binari akhir)Library (crate yang dikonsumsi orang lain)
Bentuk errorSatu tipe seragam menampung error apa punEnum terketik dengan variant eksplisit
Kontekscontext() menambah keterangan berlapis-lapisKeterangan ditanam di tiap variant
Kapan dipilihKetika pemanggil hanya perlu melaporkan errorKetika pemanggil perlu bereaksi pada jenis error

Alasan pembagian tersebut murni soal kontrak API. Library yang mengembalikan anyhow::Error memaksa seluruh pemanggilnya ke satu tipe opaq — pemanggil tidak bisa lagi membedakan “berkas tidak ada” dari “format salah” tanpa membandingkan teks pesan. Sebaliknya, aplikasi jarang perlu memilah jenis error secara programatik; aplikasi cukup melaporkan error sebaik mungkin kepada pengguna, dan di situlah konteks berlapis milik anyhow bersinar.

anyhow untuk Aplikasi #

Kita mulai dari masalah yang nyata. Fungsi std::fs::read_to_string mengembalikan std::io::Error yang jujur tetapi miskin: pesannya hanya “No such file or directory”, tanpa menyebut berkas mana yang gagal dibaca. Aplikasi yang memakai Box<dyn Error> dan operator ? bisa mengalirkan error ke atas, tetapi setiap lapisan fungsi membuang konteks yang justru paling berharga saat debug.

anyhow menjawabnya dengan tiga pilar. Pertama, tipe anyhow::Error mampu menampung error apa pun yang mengimplementasikan std::error::Error — std error, error dari crate lain, hingga enum buatan kita sendiri. Kedua, trait Context memberi metode context() dan with_context() yang membungkus error dengan keterangan baru tanpa menghapus error aslinya, membentuk error chain. Ketiga, alias anyhow::Result<T> menyederhanakan signature sehingga Result<T, anyhow::Error> cukup ditulis pendek.

Mari kita bangun aplikasi pembaca berkas konfigurasi. Buat proyek baru lalu tambahkan dependency:

bash
cargo new baca_setelan
cd baca_setelan
cargo add anyhow

Manifest proyek kini memuat satu dependency:

toml
[package]
name = "baca_setelan"
version = "0.1.0"
edition = "2024"

[dependencies]
anyhow = "1.0.104"

Kode lengkap src/main.rs berisi dua fungsi berlapis yang sama-sama menambahkan konteks:

rust
use anyhow::{Context, Result};

fn baca_berkas(nama: &str) -> Result<String> {
    std::fs::read_to_string(nama).context(format!("gagal membaca berkas '{nama}'"))
}

fn ambil_setelan() -> Result<String> {
    let isi = baca_berkas("setelan.txt").context("memuat setelan aplikasi gagal")?;
    Ok(isi.trim().to_string())
}

fn main() -> Result<()> {
    let setelan = ambil_setelan()?;
    println!("Setelan aktif: {setelan}");
    Ok(())
}

Berkas setelan.txt belum kita buat, jadi eksekusi pertama justru memperlihatkan kekuatan utama anyhow. Jalankan program dalam keadaan berkas tidak ada:

bash
cargo run

Output yang Dihasilkan:

text
Error: memuat setelan aplikasi gagal

Caused by:
    0: gagal membaca berkas 'setelan.txt'
    1: No such file or directory (os error 2)

Teks “No such file or directory” dari std::io::Error masih utuh di baris terakhir — anyhow tidak membuang error apa pun. Sekarang ciptakan berkasnya dan jalankan ulang:

bash
echo "mode produksi" > setelan.txt
cargo run

Output yang Dihasilkan:

text
Setelan aktif: mode produksi

Mari kita bedah kode di atas:

  1. Baris 1 (use anyhow::{Context, Result};): mengimpor alias Result — versi std::result::Result dengan tipe error tetap anyhow::Error — serta trait Context pemilik metode context(). Tanpa trait tersebut di scope, pemanggilan context() tidak akan diketemukan.
  2. Baris 3 (fn baca_berkas(nama: &str) -> Result<String>): signature pendek hasil alias anyhow. Fungsi ini mengembalikan isi berkas, atau error bertipe anyhow::Error.
  3. Baris 4 (std::fs::read_to_string(nama).context(...)): hasil read_to_string bertipe Result<String, std::io::Error>; context() mengubah tipe error menjadi anyhow::Error sembari membungkus std::io::Error di dalamnya. Argumen format! dipakai karena nama harus ikut tercetak di pesan.
  4. Baris 8 (let isi = baca_berkas("setelan.txt").context("memuat setelan aplikasi gagal")?;): lapisan kedua menambahkan keterangan lain di atas hasil lapisan pertama. Operator ? tetap bekerja seperti biasa — hanya tipe error-nya sudah anyhow::Error, jadi tidak ada konversi tambahan.
  5. Baris 12 (fn main() -> Result<()>): main boleh mengembalikan Result sejak Rust 1.26. Ketika program berakhir dengan Err, runtime Rust mencetak isi error tersebut dengan format Debug — dan implementasi Debug milik anyhow::Error inilah yang menghasilkan blok Caused by berisi seluruh error chain.
  6. Baris 13 (let setelan = ambil_setelan()?;): propagasi terakhir. Karena main sendiri bertipe Result<()>, operator ? menyerahkan error langsung ke runtime, bukan ke panic!.

Bagian Caused by tersusun dari lapisan terluar ke error paling dasar: keterangan ambil_setelan, keterangan baca_berkas, lalu pesan asli std::io::Error. Tiga lapisan itu terbentuk dari dua pemanggilan context() — rantai lengkapnya tampil tanpa satu baris kode pencetakan manual.

thiserror untuk Library #

Sekarang kita pindah kursi menjadi penulis library. Konsumen library kantin kita ingin membedakan “menu tidak ada” dari “input jumlah salah” — dua keadaan yang mungkin perlu reaksi berbeda di kode mereka. Enum error adalah jawabannya, dan Section Penanganan Error sudah menunjukkan harganya: implementasi Display manual, implementasi std::error::Error manual, dan From untuk tiap error bawaan yang dibungkus — semua itu berulang secara mekanis.

thiserror memangkas boilerplate tersebut lewat derive macro. Atribut #[derive(Error)] mengimplementasikan std::error::Error secara otomatis; atribut #[error("...")] pada tiap variant menghasilkan implementasi Display dengan sintaks format yang sudah kita kenal dari println!; dan atribut #[from] menghasilkan implementasi From yang sekaligus menjadikan field tersebut sebagai source — error penyebab di rantai. Buat proyeknya:

bash
cargo new pesanan_kantin
cd pesanan_kantin
cargo add thiserror
toml
[package]
name = "pesanan_kantin"
version = "0.1.0"
edition = "2024"

[dependencies]
thiserror = "2.0.21"

Kode src/main.rs — anggap bagian enum nantinya hidup di crate library, bukan di berkas main:

rust
use std::error::Error as StdError;
use std::num::ParseIntError;
use thiserror::Error;

#[derive(Debug, Error)]
enum KesalahanPesanan {
    #[error("pesanan #{id} tidak ditemukan di daftar menu")]
    TidakDitemukan { id: u32 },

    #[error("jumlah pesanan tidak valid: {pesan}")]
    JumlahTidakValid { pesan: String },

    #[error("gagal membaca jumlah sebagai angka")]
    AngkaTidakValid(#[from] ParseIntError),
}

fn cari_menu(id: u32) -> Result<&'static str, KesalahanPesanan> {
    match id {
        1 => Ok("Nasi goreng spesial"),
        2 => Ok("Es teh manis"),
        _ => Err(KesalahanPesanan::TidakDitemukan { id }),
    }
}

fn baca_jumlah(teks: &str) -> Result<u32, KesalahanPesanan> {
    let jumlah: u32 = teks.trim().parse()?;
    if jumlah == 0 {
        return Err(KesalahanPesanan::JumlahTidakValid {
            pesan: "jumlah minimal satu porsi".to_string(),
        });
    }
    Ok(jumlah)
}

fn main() {
    println!("{}", cari_menu(1).unwrap());
    println!("{}", cari_menu(9).unwrap_err());
    println!("{}", baca_jumlah("tiga").unwrap_err());

    if let Err(e) = baca_jumlah("tiga") {
        if let Some(sumber) = e.source() {
            println!("Penyebab: {sumber}");
        }
    }
}

Output yang Dihasilkan:

text
Nasi goreng spesial
pesanan #9 tidak ditemukan di daftar menu
gagal membaca jumlah sebagai angka
Penyebab: invalid digit found in string

Mari kita bedah bagian-bagian kuncinya:

  1. Baris 5 (#[derive(Debug, Error)]): dua derive sekaligus — Debug standar untuk kebutuhan pencetakan, dan Error milik thiserror yang mengimplementasikan std::error::Error untuk enum kita.
  2. Baris 7 (#[error("pesanan #{id} tidak ditemukan di daftar menu")]): teks di dalam atribut menjadi isi implementasi Display untuk variant TidakDitemukan. Penanda {id} mengambil field id milik variant tersebut, persis seperti format println!.
  3. Baris 13-14 (#[error("gagal membaca jumlah sebagai angka")] dan AngkaTidakValid(#[from] ParseIntError)): variant tuple yang membungkus ParseIntError. Atribut #[from] menghasilkan dua hal sekaligus — implementasi From<ParseIntError> sehingga operator ? pada baris 26 bekerja otomatis, dan penandaan field sebagai error penyebab sehingga metode source() mengembalikannya.
  4. Baris 40-41 (if let Err(e) = ... e.source()): pemanggil library memeriksa rantai penyebab secara manual. source() berasal dari trait std::error::Error yang diimpor sebagai StdError di baris 1, dan mengembalikan Option karena tidak semua variant punya penyebab — TidakDitemukan dan JumlahTidakValid adalah error mandiri.
  5. Baris 26 (let jumlah: u32 = teks.trim().parse()?;): parse mengembalikan ParseIntError saat gagal; #[from] mengubahnya menjadi KesalahanPesanan::AngkaTidakValid sebelum error keluar dari fungsi. Pemanggil hanya melihat tipe error library kita, bukan tipe error bawaan yang kebetulan kita pakai di dalam.

Perhatikan baris output kedua dan ketiga: unwrap_err() mengembalikan nilai error, lalu {} mencetaknya dengan format Display — teks yang persis berasal dari atribut #[error("...")]. Semua trik pencetakan pesan di Section Penanganan Error kini dihasilkan derive, tanpa satu baris impl Display pun.

Keduanya Bekerja Bersama #

Pola dunia nyata menggabungkan keduanya: library mengekspos enum thiserror, aplikasi menelan error tersebut ke dalam anyhow. Konversinya otomatis — anyhow::Error mengimplementasikan From untuk semua tipe yang mengimplementasikan std::error::Error, dan enum thiserror sudah memenuhi syarat tersebut. Proyek berikut memasang kedua crate sekaligus:

bash
cargo new aplikasi_kantin
cd aplikasi_kantin
cargo add anyhow thiserror
toml
[package]
name = "aplikasi_kantin"
version = "0.1.0"
edition = "2024"

[dependencies]
anyhow = "1.0.104"
thiserror = "2.0.21"
rust
use anyhow::{Context, Result};
use std::num::ParseIntError;
use thiserror::Error;

#[derive(Debug, Error)]
enum KesalahanKantin {
    #[error("jumlah porsi tidak boleh nol")]
    JumlahKosong,

    #[error("gagal membaca jumlah sebagai angka")]
    AngkaTidakValid(#[from] ParseIntError),
}

fn baca_jumlah(teks: &str) -> std::result::Result<u32, KesalahanKantin> {
    let jumlah: u32 = teks.trim().parse()?;
    if jumlah == 0 {
        return Err(KesalahanKantin::JumlahKosong);
    }
    Ok(jumlah)
}

fn muat_pesanan(id: u32, teks_jumlah: &str) -> Result<u32> {
    let jumlah = baca_jumlah(teks_jumlah)
        .with_context(|| format!("gagal memuat pesanan #{id}"))?;
    Ok(jumlah)
}

fn main() -> Result<()> {
    let jumlah = muat_pesanan(7, "tiga")?;
    println!("Jumlah porsi: {jumlah}");
    Ok(())
}

Output yang Dihasilkan:

text
Error: gagal memuat pesanan #7

Caused by:
    0: gagal membaca jumlah sebagai angka
    1: invalid digit found in string

Tiga baris rantai tersebut lahir dari tiga sumber berbeda: teks “gagal memuat pesanan #7” ditambahkan with_context di sisi aplikasi, teks “gagal membaca jumlah sebagai angka” berasal dari atribut #[error] di sisi library, dan baris terakhir adalah ParseIntError dari standard library. Perhatikan pula signature-nya: baca_jumlah tetap mengembalikan tipe error library yang terketik, sementara muat_pesanan bebas mengubahnya menjadi anyhow::Result begitu konteks aplikasi menempel. Pemanggilan with_context(|| ...) memakai closure agar format! hanya dieksekusi ketika memang terjadi error.

Ringkasan #

anyhow dan thiserror membagi wilayah kerja penanganan error: anyhow::Error menampung error apa pun di sisi aplikasi, context() menumpuk keterangan lapis demi lapis, dan implementasi Debug-nya mencetak seluruh error chain lengkap dengan blok Caused by. Di sisi library, #[derive(Error)] thiserror menggantikan impl Display dan impl Error manual, #[error("...")] mendefinisikan pesan tiap variant, dan #[from] menghubungkan error bawaan sebagai penyebab. Keduanya bertemu secara alami karena anyhow menerima semua tipe error standar — library tetap terketik, aplikasi tetap luwes. Chapter berikutnya melanjutkan ke crate yang mengurus pintu masuk setiap aplikasi Rust: parser argumen baris perintah bernama clap.