Rust Error Handling: From Beginner Panic to Idiomatic Result

A tour through Rust's error handling patterns — from .unwrap() to thiserror to custom error enums — with notes on when each one is appropriate.

The first thing every new Rust programmer learns is unwrap(). The second thing they learn is that unwrap() panics. The third thing — which takes longer — is that the idiomatic alternatives are actually better in almost every way.

Here’s the progression I went through, and where I’ve landed after a few years of writing production Rust.

Stage 1 — unwrap everything

When you’re fighting the borrow checker, unwrap() is a pressure-release valve:

rust
let config = std::fs::read_to_string("config.toml").unwrap();
let value: i32 = "42".parse().unwrap();

This is fine for prototyping and throwaway scripts. For anything that runs in production, it’s a time bomb — your program just panics with a non-descriptive message and a stack trace.

expect("...") is strictly better when you’re going to unwrap anyway:

main.rsrust
let config = std::fs::read_to_string("config.toml")
    .expect("failed to read config.toml");

At least now the panic message tells you what failed.

Stage 2 — Box

The next step is making functions return Result. The easiest way is to use a boxed trait object as the error type:

src/main.rsrust
use std::error::Error;

fn load_config(path: &str) -> Result<String, Box<dyn Error>> {
    let content = std::fs::read_to_string(path)?;
    Ok(content)
}

The ? operator is the key here. It’s equivalent to:

rust
match expr {
    Ok(val) => val,
    Err(e)  => return Err(e.into()),
}

Box<dyn Error> works because most error types implement std::error::Error, and ? calls .into() which boxes them. You can mix error types from different libraries with no ceremony:

src/main.rsrust
fn fetch_and_parse(url: &str) -> Result<Config, Box<dyn Error>> {
    let resp  = reqwest::blocking::get(url)?;   // reqwest::Error
    let text  = resp.text()?;                   // reqwest::Error
    let cfg   = toml::from_str::<Config>(&text)?; // toml::de::Error
    Ok(cfg)
}

The downside: callers can’t pattern-match on the error type. Box<dyn Error> erases the type. This is fine for application code but bad for library code — your callers can’t handle specific error cases programmatically.

Stage 3 — Custom error enums

For library code, define your own error enum:

src/error.rsrust
#[derive(Debug)]
pub enum AppError {
    Io(std::io::Error),
    Parse(std::num::ParseIntError),
    Config(String),
}

impl std::fmt::Display for AppError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            AppError::Io(e)     => write!(f, "I/O error: {e}"),
            AppError::Parse(e)  => write!(f, "parse error: {e}"),
            AppError::Config(s) => write!(f, "config error: {s}"),
        }
    }
}

impl std::error::Error for AppError {}

impl From<std::io::Error> for AppError {
    fn from(e: std::io::Error) -> Self { AppError::Io(e) }
}

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

Now ? works again, and callers can match on variants:

rust
match load_config("config.toml") {
    Ok(cfg)                => use_config(cfg),
    Err(AppError::Io(e))   => eprintln!("couldn't read file: {e}"),
    Err(AppError::Config(msg)) => eprintln!("bad config: {msg}"),
    Err(e)                 => eprintln!("other error: {e}"),
}

The downside: writing all those From impls and Display impls by hand is tedious. This is exactly the problem that thiserror was made to solve.

Stage 4 — thiserror

thiserror is a derive macro that writes the boilerplate for you:

src/error.rsrust
use thiserror::Error;

#[derive(Debug, Error)]
pub enum AppError {
    #[error("I/O error: {0}")]
    Io(#[from] std::io::Error),

    #[error("parse error: {0}")]
    Parse(#[from] std::num::ParseIntError),

    #[error("config error: {0}")]
    Config(String),
}

That’s it. #[from] generates the From impl. #[error("...")] generates Display. Same ergonomics, ~10× less code.

I use thiserror for every library crate I write. It’s one of those dependencies where the value-to-complexity ratio is so high that skipping it feels masochistic.

Stage 5 — anyhow for applications

For application code (binaries, integration tests, CLI tools) where you don’t need callers to match error variants, anyhow is superb:

src/main.rsrust
use anyhow::{Context, Result};

fn main() -> Result<()> {
    let config = std::fs::read_to_string("config.toml")
        .context("reading config.toml")?;

    let port: u16 = config
        .lines()
        .find(|l| l.starts_with("port"))
        .and_then(|l| l.split('=').nth(1))
        .ok_or_else(|| anyhow::anyhow!("missing port in config"))?
        .trim()
        .parse()
        .context("parsing port number")?;

    println!("Listening on port {port}");
    Ok(())
}

.context("...") wraps the error with a human-readable message. When the error propagates up, you get a chain:

Error: parsing port number

Caused by:
    0: invalid digit found in string

anyhow::Result<T> is just Result<T, anyhow::Error>. The anyhow::Error type carries a backtrace (on nightly, or when RUST_BACKTRACE=1 is set) and supports downcasting if you really need the original error type.

The rule I follow

Context Use
Prototyping / scripts .unwrap() / .expect()
Library crate public API thiserror custom enum
Application binary anyhow
Test code .unwrap() is fine

The short version: thiserror for libraries, anyhow for applications. If you’re writing a CLI or a service and you reach for Box<dyn Error>, just use anyhow — it’s the same idea but better in every way.

One more thing: Rust error handling forces you to be explicit about failure paths at the type level. It’s more work upfront than Go’s if err != nil or throwing exceptions, but the result is code where the failure modes are visible in the function signature. After a few months, you start to appreciate that.

By PatrickChoDev

You might also like