Rust error handling splits into two camps:
thiserror (callers need to match on variants)anyhow 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.
[dependencies]
thiserror = "2"
Define an error enum that covers your failure modes:
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#[derive(Debug, Error)]
pub enum AppError {
#[error("parse failed")]
Parse(#[from] ParseError),
#[error("network error")]
Network(#[from] reqwest::Error),
}
Now ? converts ParseError → AppError automatically.
[dependencies]
anyhow = "1"
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(())
}
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}"))?;
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(())
}
For small libraries where you want zero dependencies:
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.
Crates typically define a convenience alias:
pub type Result<T> = std::result::Result<T, crate::Error>;
Callers write crate::Result<Touchstone> instead of std::result::Result<Touchstone, crate::Error>.
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
}
}
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")
}
}
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)
}
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"));
}
| Situation | Recommendation |
|---|---|
| Public library, zero deps | Manual impl Error |
| Public library, deps OK | thiserror |
| Binary / CLI | anyhow |
| Mixed crate (lib + bin) | thiserror in lib, anyhow in main.rs |
| Prototyping | anyhow everywhere, refine later |