A common mistake in Rust projects is mixing application and library error styles. I use thiserror for typed library errors and anyhow for top-level binaries.
Libraries expose specific variants so callers can branch by cause. Binaries add context and preserve chains for diagnostics.
When errors cross async boundaries, structured context messages become essential. I include identifiers like device ID and operation name in every wrapped error.
This pattern keeps API surfaces precise while still making CLI and service logs readable during incidents.
Library errors with thiserror
In a library crate I define a concrete error enum. thiserror derives Display and Error, and #[from] lets ? convert underlying errors into my variants without a manual map_err at every call site.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum DeviceError {
#[error("device {id} timed out after {ms} ms")]
Timeout { id: String, ms: u64 },
#[error("malformed frame")]
Frame(#[from] FrameError),
#[error("transport failure")]
Io(#[from] std::io::Error),
}
pub fn read_frame(id: &str) -> Result<Frame, DeviceError> {
let bytes = std::fs::read(format!("/dev/{id}"))?; // io::Error -> DeviceError::Io
let frame = Frame::parse(&bytes)?; // FrameError -> DeviceError::Frame
Ok(frame)
}
The caller can still match on DeviceError::Timeout { .. } to branch by cause, which is the whole point of exposing typed variants from a library.
Binary errors with anyhow
At the top of a binary I stop caring about exact types and start caring about a readable chain. anyhow::Result plus .context(...) attaches human-facing information as the error travels up.
use anyhow::{Context, Result};
fn main() -> Result<()> {
let cfg = load_config("gateway.toml")
.context("failed to load gateway config")?;
let frame = read_frame(&cfg.device_id)
.with_context(|| format!("reading device {}", cfg.device_id))?;
process(frame).context("processing telemetry frame")?;
Ok(())
}
If read_frame fails deep inside, the operator sees the full chain instead of a bare Timeout:
Error: reading device sensor-07
Caused by:
0: device sensor-07 timed out after 500 ms
Converting at the binary boundary
The rule I follow: library crates return thiserror enums so callers can branch programmatically; binaries wrap everything in anyhow so failures carry context for logs. The conversion happens for free at the boundary because anyhow::Error implements From for any std::error::Error, so a ? on a DeviceError inside an anyhow::Result function just works. I never put anyhow in a library's public API — that would rob downstream callers of the typed variants they need to make decisions.
One constraint worth stating explicitly: errors that travel across async tasks or thread boundaries must be Send + Sync + 'static. anyhow::Error already requires this, and thiserror-derived enums satisfy it automatically as long as every field does. If a variant wraps something non-Send (a raw pointer, an Rc, a non-Send handle), the whole error stops crossing .await points and the compiler will tell you at the boundary rather than at runtime.
Sources
- Imported from the previous version of the site (gaborl.hu). Original publication around 2024-09-23.
- thiserror crate documentation
- anyhow crate documentation
Update history
- — Adopted from previous gaborl.hu (Hugo) site via legacy import.