Error Handling #

Rust doesn’t have try-catch. This isn’t an oversight — it’s a deliberate design decision. In languages with exceptions, a function can throw an error at any time without declaring it in its signature, leaving callers unsure whether they need to handle errors or not. Rust rejects this uncertainty: every function that can fail must declare it explicitly in its return type, and callers are required to handle the result. The result is code that’s more verbose on one hand, but with a strong guarantee: if a program compiles, no error can slip through unhandled silently. This article covers Rust’s two error-handling paths — Result for recoverable errors and panic! for unrecoverable ones — along with the idiomatic patterns that keep error-handling code concise and expressive.

The Two Categories of Errors in Rust #

Rust divides errors into two fundamentally different categories, and handles them with different mechanisms:

flowchart TD
    E[Errors in Rust]
    E --> R["Recoverable\nCan be recovered\nProgram can continue"]
    E --> U["Unrecoverable\nBug or unexpected condition\nProgram must stop"]

    R --> R1["Result<T, E>\nOk or Err value\nThe caller must handle it"]
    R --> R2["Option<T>\nSome or None value\nFor 'no value present'"]

    U --> U1["panic!\nUnwinds the stack\nPrints a message and stops"]
    U --> U2["assert! / assert_eq!\nunreachable! / todo!\nMeaningful panic variants"]

The rule of thumb is simple: use Result for errors that reasonably occur in normal use (file not found, invalid input, failed connection), and panic! for conditions that shouldn’t be possible (index out of bounds, violated invariants, logic bugs).


Result<T, E> — Recoverable Errors #

Result is a built-in enum with two variants:

// Definition in the standard library
enum Result<T, E> {
    Ok(T),   // operation succeeded, carrying a value of type T
    Err(E),  // operation failed, carrying an error of type E
}

Every function that can fail returns a Result. The compiler forces callers to handle both:

use std::fs;
use std::num::ParseIntError;

// A function that might fail — declared in the signature
fn baca_file(path: &str) -> Result<String, std::io::Error> {
    fs::read_to_string(path)
}

fn parse_angka(s: &str) -> Result<i32, ParseIntError> {
    s.trim().parse::<i32>()
}

fn main() {
    // Handling with match — the most explicit
    match baca_file("config.txt") {
        Ok(isi) => println!("File contents: {}", isi),
        Err(e)  => println!("Failed to read file: {}", e),
    }

    // Handling with if let — when you only care about Ok
    if let Ok(n) = parse_angka("42") {
        println!("Number: {}", n);
    }

    // unwrap_or — a default value on Err
    let n = parse_angka("not a number").unwrap_or(0);
    println!("With default: {}", n);

    // unwrap_or_else — compute the default value lazily
    let n2 = parse_angka("xyz").unwrap_or_else(|e| {
        eprintln!("Parse failed: {}", e);
        -1
    });
    println!("With fallback: {}", n2);
}

Important Methods on Result #

fn main() {
    let ok: Result<i32, &str> = Ok(42);
    let err: Result<i32, &str> = Err("gagal");

    // Check status
    println!("{} {}", ok.is_ok(), ok.is_err());    // true false
    println!("{} {}", err.is_ok(), err.is_err());  // false true

    // Transform the Ok value with map
    let doubled = ok.map(|n| n * 2);
    println!("{:?}", doubled);  // Ok(84)

    // Transform the error with map_err
    let dengan_pesan = err.map_err(|e| format!("Error: {}", e));
    println!("{:?}", dengan_pesan);  // Err("Error: gagal")

    // and_then — chain operations that can fail
    let hasil = parse_angka("10")
        .and_then(|n| {
            if n > 0 { Ok(n * 2) }
            else { Err("harus positif".parse::<i32>().unwrap_err()) }
        });

    // ok() — convert Result to Option (discard error information)
    let sebagai_option: Option<i32> = ok.ok();
    println!("{:?}", sebagai_option);  // Some(42)

    // ANTI-PATTERN: unwrap() in production code without checking
    // err.unwrap();  // panic: called `Result::unwrap()` on an `Err` value
    // Use expect() with an informative message while prototyping:
    // err.expect("An operation that should always succeed");
}

fn parse_angka(s: &str) -> Result<i32, std::num::ParseIntError> {
    s.parse()
}

The ? Operator — Automatic Error Propagation #

The ? operator is the most idiomatic way to propagate errors up the call stack. It extracts the Ok value on success, or immediately return Err(...) on failure — one character replaces an entire match block:

use std::fs;
use std::io;
use std::num::ParseIntError;

// Without the ? operator — verbose
fn baca_dan_parse_verbose(path: &str) -> Result<i32, io::Error> {
    let isi = match fs::read_to_string(path) {
        Ok(s) => s,
        Err(e) => return Err(e),
    };
    // can't directly return ParseIntError because the types differ
    // must convert manually
    Ok(42) // simplified
}

// With the ? operator — clean and linear
fn baca_dan_parse(path: &str) -> Result<i32, Box<dyn std::error::Error>> {
    let isi = fs::read_to_string(path)?;  // propagates io::Error
    let angka: i32 = isi.trim().parse()?; // propagates ParseIntError
    Ok(angka * 2)
}

fn main() {
    match baca_dan_parse("angka.txt") {
        Ok(n) => println!("Result: {}", n),
        Err(e) => eprintln!("Error: {}", e),
    }
}

? in main() #

Since Rust 2018, main() can return a Result, allowing ? to be used directly inside it:

use std::fs;
use std::io;

fn main() -> Result<(), io::Error> {
    let isi = fs::read_to_string("config.txt")?;
    println!("Configuration:\n{}", isi);
    Ok(())  // main succeeded
}

If Err is returned from main, the program prints the error and exits with a non-zero code — exactly like proper CLI error handling.


Custom Error Types #

For larger libraries or applications, defining your own error type enables more descriptive errors that are easier for callers to handle:

use std::fmt;
use std::num::ParseIntError;

// Custom error type with an enum
#[derive(Debug)]
enum AppError {
    IoError(std::io::Error),
    ParseError(ParseIntError),
    Validasi(String),
}

// Implement Display for user-friendly messages
impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            AppError::IoError(e)    => write!(f, "I/O error: {}", e),
            AppError::ParseError(e) => write!(f, "Parsing error: {}", e),
            AppError::Validasi(msg) => write!(f, "Validation failed: {}", msg),
        }
    }
}

// Implement std::error::Error so it can be used as Box<dyn Error>
impl std::error::Error for AppError {}

// The From trait enables automatic conversion via the ? operator
impl From<std::io::Error> for AppError {
    fn from(e: std::io::Error) -> Self {
        AppError::IoError(e)
    }
}

impl From<ParseIntError> for AppError {
    fn from(e: ParseIntError) -> Self {
        AppError::ParseError(e)
    }
}

fn proses_file(path: &str) -> Result<i32, AppError> {
    let isi = std::fs::read_to_string(path)?;  // io::Error → AppError::IoError automatically via From
    let n: i32 = isi.trim().parse()?;           // ParseIntError → AppError::ParseError automatically

    if n < 0 {
        return Err(AppError::Validasi(format!("Value {} must be positive", n)));
    }

    Ok(n * 2)
}

fn main() {
    match proses_file("data.txt") {
        Ok(hasil) => println!("Result: {}", hasil),
        Err(AppError::IoError(e)) => eprintln!("File error: {}", e),
        Err(AppError::ParseError(e)) => eprintln!("Parse error: {}", e),
        Err(AppError::Validasi(msg)) => eprintln!("Validation: {}", msg),
    }
}

Box<dyn Error> for Dynamic Errors #

When the caller doesn’t need to distinguish specific error types, Box<dyn std::error::Error> is a quick way to accept all kinds of errors:

use std::error::Error;

// Accept all error types — practical for scripts and binaries
fn baca_konfigurasi(path: &str) -> Result<(String, u16), Box<dyn Error>> {
    let isi = std::fs::read_to_string(path)?;       // io::Error
    let mut baris = isi.lines();

    let host = baris.next()
        .ok_or("Host line not found")?       // &str as an error
        .to_string();

    let port: u16 = baris.next()
        .ok_or("Port line not found")?
        .trim()
        .parse()?;                                   // ParseIntError

    Ok((host, port))
}

fn main() -> Result<(), Box<dyn Error>> {
    let (host, port) = baca_konfigurasi("server.conf")?;
    println!("Server: {}:{}", host, port);
    Ok(())
}
For libraries that others will use, define a custom error type so callers can handle different error cases separately. Box<dyn Error> is better suited for binaries or prototypes where the error type details don’t matter to the caller.

Option<T> — Absence of a Value Isn’t an Error #

Option is used when the absence of a value is a normal condition rather than a sign of a problem — unlike Result, which carries information about what went wrong:

fn cari_pengguna(id: u32) -> Option<String> {
    match id {
        1 => Some(String::from("Budi")),
        2 => Some(String::from("Sari")),
        _ => None,  // not found — not an error, just absent
    }
}

fn ambil_pertama(v: &[i32]) -> Option<&i32> {
    v.first()  // None if empty, not an error
}

fn main() {
    // Various ways to handle Option
    let nama = cari_pengguna(1);

    // match — the most explicit
    match nama {
        Some(n) => println!("Found: {}", n),
        None    => println!("Not found"),
    }

    // unwrap_or
    let nama = cari_pengguna(99).unwrap_or(String::from("Anonim"));
    println!("{}", nama);

    // map — transform if Some
    let panjang = cari_pengguna(2).map(|n| n.len());
    println!("{:?}", panjang);  // Some(4)

    // and_then — chain Option operations
    let hasil = cari_pengguna(1)
        .and_then(|nama| if nama.len() > 3 { Some(nama) } else { None });
    println!("{:?}", hasil);

    // ? also works in functions returning Option
    fn panjang_nama(id: u32) -> Option<usize> {
        let nama = cari_pengguna(id)?;  // return None if absent
        Some(nama.len())
    }
    println!("{:?}", panjang_nama(1));  // Some(4)
    println!("{:?}", panjang_nama(99)); // None
}

Converting Between Option and Result #

fn main() {
    // Option → Result with ok_or / ok_or_else
    let angka: Option<i32> = Some(42);
    let sebagai_result: Result<i32, &str> = angka.ok_or("no value");
    println!("{:?}", sebagai_result);  // Ok(42)

    let none: Option<i32> = None;
    let err: Result<i32, &str> = none.ok_or("value absent");
    println!("{:?}", err);  // Err("value absent")

    // Result → Option with ok() and err()
    let result: Result<i32, &str> = Ok(42);
    let opt: Option<i32> = result.ok();  // discard error info
    println!("{:?}", opt);  // Some(42)

    let result_err: Result<i32, &str> = Err("gagal");
    let err_opt: Option<&str> = result_err.err();  // take the error value
    println!("{:?}", err_opt);  // Some("gagal")
}

panic! — Unrecoverable Errors #

panic! stops the current thread, cleans up the stack (unwinds), and prints an error message along with a backtrace. Use it only for conditions that shouldn’t be possible in correct usage:

fn akses_aman(data: &[i32], indeks: usize) -> i32 {
    // ANTI-PATTERN: panicking for an error that could be handled
    // if indeks >= data.len() {
    //     panic!("Index {} out of bounds", indeks);
    // }
    // data[indeks]

    // CORRECT: return an Option or Result
    data.get(indeks).copied().unwrap_or_else(|| {
        panic!("Bug: index {} should always be valid at this point", indeks)
    })
}

// A legitimate use of panic — an invariant that must not be violated
fn buat_koneksi_pool(ukuran: usize) {
    assert!(ukuran > 0, "Pool size must be greater than 0, got: {}", ukuran);
    assert!(ukuran <= 100, "Pool size max is 100, got: {}", ukuran);
    // ... continue initialization
}

fn main() {
    // panic! directly
    // panic!("This is an unrecoverable error");

    // assert! — panics if the condition is false
    let x = 5;
    assert!(x > 0, "x must be positive");
    assert_eq!(x, 5, "x must be 5");
    assert_ne!(x, 0, "x must not be zero");

    // unreachable! — marks code that should never be reached
    let nilai = 2;
    match nilai {
        1 => println!("one"),
        2 => println!("two"),
        3 => println!("three"),
        _ => unreachable!("nilai can only be 1, 2, or 3"),
    }

    // todo! and unimplemented! — placeholders during development
    // todo!("Implement this function later");
    // unimplemented!("Not implemented yet");
}

panic! vs Result — When to Choose #

Use panic! if:
  ✓ The condition is a bug that shouldn't be possible
  ✓ An invariant guaranteed by the API contract is violated
  ✓ Initialization fails and the program can't continue at all
  ✓ Prototype/test code (use .unwrap() and .expect())

Use Result if:
  ✓ The error is a reasonable possibility in normal use
  ✓ File not found, connection timeout, invalid input
  ✓ The caller needs to know what went wrong and might be able to handle it
  ✓ Library code — don't force library users to panic

Idiomatic Error Handling Patterns #

Chaining Operations with ? #

use std::io;
use std::fs;

#[derive(Debug)]
struct Konfigurasi {
    host: String,
    port: u16,
    debug: bool,
}

fn muat_konfigurasi(path: &str) -> Result<Konfigurasi, Box<dyn std::error::Error>> {
    let isi = fs::read_to_string(path)?;
    let mut lines = isi.lines();

    let host = lines.next()
        .ok_or("Field 'host' not found")?
        .trim()
        .to_string();

    let port = lines.next()
        .ok_or("Field 'port' not found")?
        .trim()
        .parse::<u16>()?;

    let debug = lines.next()
        .ok_or("Field 'debug' not found")?
        .trim()
        .parse::<bool>()?;

    Ok(Konfigurasi { host, port, debug })
}

Collecting Vec<Result> into Result<Vec> #

fn main() {
    let input = vec!["1", "2", "3", "4", "5"];

    // Collect everything into a Result — fails at the first error element
    let angka: Result<Vec<i32>, _> = input.iter()
        .map(|s| s.parse::<i32>())
        .collect();
    println!("{:?}", angka);  // Ok([1, 2, 3, 4, 5])

    let input_rusak = vec!["1", "dua", "3"];
    let gagal: Result<Vec<i32>, _> = input_rusak.iter()
        .map(|s| s.parse::<i32>())
        .collect();
    println!("{:?}", gagal);  // Err(ParseIntError)

    // Skip the errors — only take the successes
    let hanya_valid: Vec<i32> = input_rusak.iter()
        .filter_map(|s| s.parse::<i32>().ok())
        .collect();
    println!("{:?}", hanya_valid);  // [1, 3]
}

The map + and_then Pipeline Pattern #

fn validasi_usia(input: &str) -> Result<u8, String> {
    input.trim()
        .parse::<u8>()
        .map_err(|_| format!("'{}' is not a valid number", input))
        .and_then(|usia| {
            if usia >= 18 {
                Ok(usia)
            } else {
                Err(format!("Age {} is below the minimum of 18", usia))
            }
        })
}

fn main() {
    println!("{:?}", validasi_usia("25"));    // Ok(25)
    println!("{:?}", validasi_usia("15"));    // Err("Age 15 is below the minimum of 18")
    println!("{:?}", validasi_usia("abc"));   // Err("'abc' is not a valid number")
    println!("{:?}", validasi_usia("999"));   // Err("'999' is not a valid number") — u8 overflow
}

Summary #

  • Rust has no exceptions — instead, all recoverable errors are represented as Result<T, E> values in function return types. The compiler forces callers to handle them.
  • Result for reasonable errors, panic! for bugs — file not found → Result; accessing a supposedly valid index that turns out out of bounds → panic!.
  • The ? operator propagates errors automatically — extracts Ok or immediately returns Err to the caller. Can be used in functions returning Result or Option.
  • ? in main() is possible — return Result<(), E> from main to use ? directly without unwrap.
  • Custom error types via enums — define enum AppError with one variant per category, implement Display, Error, and From for automatic conversion via ?.
  • Box<dyn Error> for prototyping — accepts all error types, useful in binaries and scripts but less informative for libraries.
  • The From trait enables automatic conversion — if impl From<io::Error> for AppError exists, then io::Error converts automatically when using ?.
  • Option isn’t a subset of Result — they have different roles: Option for “a value may be absent”, Result for “an operation may fail with error info”. Convert between them via .ok(), .ok_or(), and .ok_or_else().
  • collect::<Result<Vec<_>, _>>() — collects an iterator of Result into Result<Vec>, failing at the first Err element. Or use filter_map to continue and skip errors.

← Previous: Traits   Next: List →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact