Rust

Error Handling Patterns

Practical error handling in Rust libraries and applications using thiserror, anyhow, and custom types

The Two Worlds

Rust error handling splits into two camps:

  • Libraries → custom error types with thiserror (callers need to match on variants)
  • Applicationsanyhow for ergonomic error propagation (you just want the message)

Pick the right tool for the context. Many projects use both — thiserror in library crates, anyhow in the binary/CLI.

Library Errors with thiserror

Cargo.toml
[dependencies]
thiserror = "2"

Define an error enum that covers your failure modes:

src/error.rs
use thiserror::Error;

#[derive(Debug, Error)]
pub enum ParseError {
    #[error("invalid frequency: {value} Hz (must be positive)")]
    InvalidFrequency { value: f64 },

    #[error("unsupported format: {0}")]
    UnsupportedFormat(String),

    #[error("I/O error reading {path}")]
    Io {
        path: String,
        #[source]
        source: std::io::Error,
    },
}

Key attributes:

  • #[error("...")] — generates Display with format string
  • #[source] — chains the underlying cause (shows up in Error::source())
  • #[from] — auto-generates From<T> for ? conversion

Using #from for automatic conversion

#[derive(Debug, Error)]
pub enum AppError {
    #[error("parse failed")]
    Parse(#[from] ParseError),

    #[error("network error")]
    Network(#[from] reqwest::Error),
}

Now ? converts ParseErrorAppError automatically.

Application Errors with anyhow

Cargo.toml
[dependencies]
anyhow = "1"
src/main.rs
use anyhow::{Context, Result};

fn load_config(path: &str) -> Result<Config> {
    let content = std::fs::read_to_string(path)
        .context(format!("failed to read config from {path}"))?;

    let config: Config = toml::from_str(&content)
        .context("invalid TOML in config file")?;

    Ok(config)
}

fn main() -> Result<()> {
    let config = load_config("settings.toml")?;
    run_app(config)?;
    Ok(())
}

When to use .context()

Add .context() at boundary crossings — where a low-level error needs human-readable meaning:

// Bad: raw io::Error with no context
let file = std::fs::File::open(path)?;

// Good: explains what we were trying to do
let file = std::fs::File::open(path)
    .context(format!("opening touchstone file {path}"))?;

anyhow::bail! and ensure!

use anyhow::{bail, ensure};

fn validate_gain(gain_db: f64) -> Result<()> {
    ensure!(gain_db.is_finite(), "gain must be finite, got {gain_db}");

    if gain_db > 60.0 {
        bail!("gain of {gain_db} dB is unrealistic for a single stage");
    }

    Ok(())
}

Custom Error Types (No Dependencies)

For small libraries where you want zero dependencies:

src/error.rs
use std::fmt;

#[derive(Debug)]
pub enum ConvertError {
    NegativeLinear(f64),
    InvalidRatio { num: f64, den: f64 },
}

impl fmt::Display for ConvertError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::NegativeLinear(v) => write!(f, "cannot convert negative value {v} to dB"),
            Self::InvalidRatio { num, den } => write!(f, "invalid ratio {num}/{den}"),
        }
    }
}

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

This is what rfconversions and similar zero-dep crates should use — thiserror is convenient but adds a proc-macro dependency.

The Result Type Alias Pattern

Crates typically define a convenience alias:

src/lib.rs
pub type Result<T> = std::result::Result<T, crate::Error>;

Callers write crate::Result<Touchstone> instead of std::result::Result<Touchstone, crate::Error>.

Patterns for RF/Engineering Code

Validation at the boundary

Parse and validate inputs once, then work with guaranteed-valid types:

pub struct Frequency(f64);

impl Frequency {
    pub fn from_hz(hz: f64) -> Result<Self, ParseError> {
        if hz <= 0.0 || !hz.is_finite() {
            return Err(ParseError::InvalidFrequency { value: hz });
        }
        Ok(Self(hz))
    }

    /// Returns frequency in Hz (always positive and finite).
    pub fn hz(&self) -> f64 {
        self.0
    }
}

Fallible vs. panicking APIs

Offer both when the checked version has a common "known-good" use case:

impl Frequency {
    /// Returns `None` if `hz` is not positive and finite.
    pub fn new(hz: f64) -> Option<Self> { /* ... */ }

    /// # Panics
    /// Panics if `hz` is not positive and finite.
    pub fn new_unchecked(hz: f64) -> Self {
        Self::new(hz).expect("frequency must be positive and finite")
    }
}

Collecting multiple errors

When parsing a file with many records, collect errors instead of failing on the first one:

fn parse_all(lines: &[&str]) -> (Vec<Record>, Vec<ParseError>) {
    let mut records = Vec::new();
    let mut errors = Vec::new();

    for line in lines {
        match parse_record(line) {
            Ok(r) => records.push(r),
            Err(e) => errors.push(e),
        }
    }

    (records, errors)
}

Testing Errors

Use assert!(result.is_err()) or match on specific variants:

#[test]
fn negative_frequency_returns_error() {
    let result = Frequency::from_hz(-1.0);
    assert!(matches!(result, Err(ParseError::InvalidFrequency { value }) if value == -1.0));
}

With anyhow, test the error message:

#[test]
fn bad_config_has_context() {
    let err = load_config("/nonexistent").unwrap_err();
    assert!(err.to_string().contains("failed to read config"));
}

Decision Guide

SituationRecommendation
Public library, zero depsManual impl Error
Public library, deps OKthiserror
Binary / CLIanyhow
Mixed crate (lib + bin)thiserror in lib, anyhow in main.rs
Prototypinganyhow everywhere, refine later

Further Reading