15 / 20 · Day 5
Day 5 · Concept 15

Custom errors & thiserror

In production, return a real error enum so callers can match on variants. thiserror derives the boilerplate. For application boundaries, anyhow gives you ergonomic Box-of-anything errors.


1 · The hand-rolled version

rust src/main.rs · before thiserror
use std::fmt;

#[derive(Debug)]
enum AppError {
    NotFound,
    Parse(String),
    Io(std::io::Error),
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            AppError::NotFound   => write!(f, "not found"),
            AppError::Parse(s)   => write!(f, "parse error: {}", s),
            AppError::Io(e)      => write!(f, "io error: {}", e),
        }
    }
}

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

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

Three impls per variant. Workable; tedious.

2 · The thiserror version

rust src/main.rs · same thing, less code
use thiserror::Error;

#[derive(Debug, Error)]
pub enum AppError {
    #[error("not found")]
    NotFound,
    #[error("parse error: {0}")]
    Parse(String),
    #[error("io error")]
    Io(#[from] std::io::Error),
}

#[derive(Error)] writes Display + Error; #[from] writes the From impl. Cargo.toml: thiserror = "1".

3 · anyhow — for applications, not libraries

rust src/main.rs · anyhow as catchall
use anyhow::{Context, Result};

fn load_config(path: &str) -> Result<String> {
    let raw = std::fs::read_to_string(path)
        .with_context(|| format!("reading {}", path))?;
    Ok(raw)
}

fn main() -> Result<()> {
    let cfg = load_config("config.toml")?;
    println!("{}", cfg);
    Ok(())
}

anyhow::Result<T> is Result<T, anyhow::Error> where anyhow::Error wraps anything. .context() adds a message at each layer — the resulting error reads like a stack trace.

The rule. Libraries return Result<T, MyError> using thiserror so callers can match. Binaries return anyhow::Result because they only need to log-and-exit.

4 · Common mistakes

  • Stringly-typed errors (Result<T, String>). Loses type info. Use an enum.
  • Mixing anyhow in a library. Forces every consumer to depend on it. Use thiserror.
  • Forgetting #[from] on the variant. Then ? can't auto-convert.

5 · When it clicks

  • You reach for thiserror in libraries and anyhow in binaries automatically.
  • Every error gets a .context() at the boundary it crosses.
  • You think of errors as data — matchable, recoverable — not as exceptions.
Found this useful?