The first thing every new Rust programmer learns is unwrap(). The second thing they learn is that unwrap() panics. The third thing — which takes longer — is that the idiomatic alternatives are actually better in almost every way.
Here’s the progression I went through, and where I’ve landed after a few years of writing production Rust.
Stage 1 — unwrap everything
When you’re fighting the borrow checker, unwrap() is a pressure-release valve:
let config = std::fs::read_to_string("config.toml").unwrap();
let value: i32 = "42".parse().unwrap();
This is fine for prototyping and throwaway scripts. For anything that runs in production, it’s a time bomb — your program just panics with a non-descriptive message and a stack trace.
expect("...") is strictly better when you’re going to unwrap anyway:
let config = std::fs::read_to_string("config.toml")
.expect("failed to read config.toml");
At least now the panic message tells you what failed.
Stage 2 — Box
The next step is making functions return Result. The easiest way is to use a boxed trait object as the error type:
use std::error::Error;
fn load_config(path: &str) -> Result<String, Box<dyn Error>> {
let content = std::fs::read_to_string(path)?;
Ok(content)
}
The ? operator is the key here. It’s equivalent to:
match expr {
Ok(val) => val,
Err(e) => return Err(e.into()),
}
Box<dyn Error> works because most error types implement std::error::Error, and ? calls .into() which boxes them. You can mix error types from different libraries with no ceremony:
fn fetch_and_parse(url: &str) -> Result<Config, Box<dyn Error>> {
let resp = reqwest::blocking::get(url)?; // reqwest::Error
let text = resp.text()?; // reqwest::Error
let cfg = toml::from_str::<Config>(&text)?; // toml::de::Error
Ok(cfg)
}
The downside: callers can’t pattern-match on the error type. Box<dyn Error> erases the type. This is fine for application code but bad for library code — your callers can’t handle specific error cases programmatically.
Stage 3 — Custom error enums
For library code, define your own error enum:
#[derive(Debug)]
pub enum AppError {
Io(std::io::Error),
Parse(std::num::ParseIntError),
Config(String),
}
impl std::fmt::Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AppError::Io(e) => write!(f, "I/O error: {e}"),
AppError::Parse(e) => write!(f, "parse error: {e}"),
AppError::Config(s) => write!(f, "config error: {s}"),
}
}
}
impl std::error::Error for AppError {}
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self { AppError::Io(e) }
}
impl From<std::num::ParseIntError> for AppError {
fn from(e: std::num::ParseIntError) -> Self { AppError::Parse(e) }
}
Now ? works again, and callers can match on variants:
match load_config("config.toml") {
Ok(cfg) => use_config(cfg),
Err(AppError::Io(e)) => eprintln!("couldn't read file: {e}"),
Err(AppError::Config(msg)) => eprintln!("bad config: {msg}"),
Err(e) => eprintln!("other error: {e}"),
}
The downside: writing all those From impls and Display impls by hand is tedious. This is exactly the problem that thiserror was made to solve.
Stage 4 — thiserror
thiserror is a derive macro that writes the boilerplate for you:
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("parse error: {0}")]
Parse(#[from] std::num::ParseIntError),
#[error("config error: {0}")]
Config(String),
}
That’s it. #[from] generates the From impl. #[error("...")] generates Display. Same ergonomics, ~10× less code.
I use thiserror for every library crate I write. It’s one of those dependencies where the value-to-complexity ratio is so high that skipping it feels masochistic.
Stage 5 — anyhow for applications
For application code (binaries, integration tests, CLI tools) where you don’t need callers to match error variants, anyhow is superb:
use anyhow::{Context, Result};
fn main() -> Result<()> {
let config = std::fs::read_to_string("config.toml")
.context("reading config.toml")?;
let port: u16 = config
.lines()
.find(|l| l.starts_with("port"))
.and_then(|l| l.split('=').nth(1))
.ok_or_else(|| anyhow::anyhow!("missing port in config"))?
.trim()
.parse()
.context("parsing port number")?;
println!("Listening on port {port}");
Ok(())
}
.context("...") wraps the error with a human-readable message. When the error propagates up, you get a chain:
Error: parsing port number
Caused by:
0: invalid digit found in string
anyhow::Result<T> is just Result<T, anyhow::Error>. The anyhow::Error type carries a backtrace (on nightly, or when RUST_BACKTRACE=1 is set) and supports downcasting if you really need the original error type.
The rule I follow
| Context | Use |
|---|---|
| Prototyping / scripts | .unwrap() / .expect() |
| Library crate public API | thiserror custom enum |
| Application binary | anyhow |
| Test code | .unwrap() is fine |
The short version: thiserror for libraries, anyhow for applications. If you’re writing a CLI or a service and you reach for Box<dyn Error>, just use anyhow — it’s the same idea but better in every way.
One more thing: Rust error handling forces you to be explicit about failure paths at the type level. It’s more work upfront than Go’s if err != nil or throwing exceptions, but the result is code where the failure modes are visible in the function signature. After a few months, you start to appreciate that.