Custom errors, thiserror and anyhow

Rust · Advanced · 15 min

Good error types make failures diagnosable; the thiserror/anyhow split is the ecosystem's standard pattern.

A proper error type implements `std::error::Error`, which requires Debug and Display, and optionally provides source() to expose the underlying cause. Callers can then print it, box it as Box<dyn Error + Send + Sync>, and walk the chain of causes.

For a library, define an enum of the failures callers might want to react to:

#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
    #[error("cannot read {path}")]
    Io { path: String, #[source] source: std::io::Error },
    #[error("invalid port {0}")]
    BadPort(u32),
    #[error(transparent)]
    Parse(#[from] std::num::ParseIntError),
}

The thiserror derive writes the Display, Error::source and From impls you would otherwise write by hand; it adds nothing to your public API. Keep variants meaningful: callers match on them.

For an application, you usually just want to report what went wrong with enough context. anyhow provides anyhow::Result<T> (with an anyhow::Error that wraps any error), .context("loading config") and .with_context(|| format!(...)) on both Result and Option, plus bail! and ensure! macros. Printing with {:#} shows the whole cause chain on one line, and {:?} adds a backtrace when RUST_BACKTRACE=1.

Guidelines that hold up:

  • Do not put the source error's message into your own Display and also return it from source(), or reporters print it twice.
  • Make library errors Send + Sync + 'static so they work across threads and async tasks.
  • Mark public error enums #[non_exhaustive] if you may add variants later.
  • Libraries should not depend on anyhow in their public API; applications can freely mix both.

The example below implements the same pattern without crates, so you can see what the derive generates.

Example

use std::{error::Error, fmt, num::ParseIntError};
#[derive(Debug)] enum ConfigError { MissingKey(&'static str), BadPort(ParseIntError) }
impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            Self::MissingKey(k) => write!(f, "missing key `{k}`"),
            Self::BadPort(_) => write!(f, "port is not a number"), // the cause comes via source()
        }
    }
}
impl Error for ConfigError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self { Self::BadPort(e) => Some(e), _ => None }
    }
}
impl From<ParseIntError> for ConfigError { // what thiserror's #[from] generates
    fn from(e: ParseIntError) -> Self { Self::BadPort(e) }
}
fn port(line: &str) -> Result<u16, ConfigError> {
    Ok(line.strip_prefix("port=").ok_or(ConfigError::MissingKey("port"))?.parse()?)
}
fn main() {
    let err = port("port=http").unwrap_err();
    println!("{err}; caused by: {}", err.source().unwrap()); // port is not a number; caused by: invalid digit found in string
}

Remember

  • Error types implement Debug + Display + Error, exposing causes via source()
  • Libraries: specific enums, often derived with thiserror
  • Applications: anyhow with .context() for readable chains

Watch out for

  • Returning Result<T, String> from libraries, which callers cannot match on or chain.

Check yourself

  1. Which crate split is the common convention?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: doc.rust-lang.org/std/error/trait.Error.html