Prefer Idiomatic Error Types
Learn why structured error types lead to better error handling in Rust, and how crates like thiserror and anyhow help you write idiomatic, maintainable code.
When a function can fail in more than one way, the error type you choose shapes how callers can react. A Result<T, String> might feel easy to write, but it forces anyone using your code to parse error messages just to understand what went wrong. Rust's type system lets you do better.
This section covers the conventions and tools that make error types both precise and easy to work with. You'll see how to build error enums that carry structured information, how the ? operator ties into type conversions, and where the crates thiserror and anyhow fit into the picture.
Why a Plain String Is Not Enough
A function like this compiles and runs:
fn read_config(path: &str) -> Result<String, String> {
std::fs::read_to_string(path)
.map_err(|e| format!("could not read {path}: {e}"))
}
The caller gets back a String error. But what can the caller actually do with that string? Match on it? That breaks the moment the message changes. Translate it? Unreliable. Distinguish between “file missing” and “permission denied” without fragile substring checks? Impossible.
String Error Types Break Callers:
Using String as an error type discards the structure of the original error. Callers lose the ability to react programmatically. Only use String when you are certain no downstream code will ever need to inspect the error variant.
An idiomatic error type instead uses an enum, where each variant represents a distinct failure mode. The caller can match on the variant and handle each case appropriately — the compiler will even tell them if they forget one.
The Core Ingredients: Error Trait and Enums
The standard library defines the std::error::Error trait. Any type that implements Error must also implement Display and Debug. That means your error type can be printed for users (Display) and logged for developers (Debug). The trait also provides an optional source() method that exposes an underlying cause.
Here is a minimal custom error type built by hand:
use std::fmt;
#[derive(Debug)]
enum ConfigError {
NotFound(String),
ParseError(std::num::ParseIntError),
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::NotFound(path) => write!(f, "config file not found: {path}"),
ConfigError::ParseError(e) => write!(f, "invalid number in config: {e}"),
}
}
}
impl std::error::Error for ConfigError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ConfigError::ParseError(e) => Some(e),
ConfigError::NotFound(_) => None,
}
}
}
The enum gives the error a shape. Display and Error make it behave like any standard error. This foundation is already far more useful than a String.
But writing those Display and Error blocks for every enum gets tedious. That is where thiserror comes in — it generates all of that from a single derive macro.
Automating Error Boilerplate with thiserror
For library crates, the idiomatic choice is the thiserror crate. It provides a #[derive(Error)] that implements Display, Error, and From conversions, all driven by attributes on your enum variants.
Here is the same ConfigError written with thiserror:
use thiserror::Error;
#[derive(Error, Debug)]
enum ConfigError {
#[error("config file not found: {0}")]
NotFound(String),
#[error("invalid number in config: {0}")]
ParseError(#[from] std::num::ParseIntError),
}
Four lines of attribute annotations replace the manual trait implementations. The #[error("...")] attribute supplies the Display message. The #[from] attribute on ParseError does something critical: it automatically implements From<std::num::ParseIntError> for ConfigError. That means the ? operator will convert a ParseIntError into ConfigError::ParseError without you writing any .map_err().
fn parse_port(raw: &str) -> Result<u16, ConfigError> {
let port: u16 = raw.parse()?; // ParseIntError converted automatically
Ok(port)
}
thiserror Makes Error Handling Disappear:
When every error variant that can arise from your library implements From for your error type, the ? operator handles propagation and conversion in a single character. The resulting code reads like a straight line of success logic, with error handling woven into the type system.
The source() method is also generated: for any variant that wraps an inner error type, thiserror automatically returns it from source(). This preserves the full error chain, which callers can inspect with methods like std::error::Error::source() or by printing the error with {:#} to see all causes.
Handling Errors in Applications with anyhow
When you are building an executable — a binary, not a library — you often do not need to distinguish between error variants. You want to report what went wrong, with enough context for a human to fix it, and then exit. The anyhow crate is designed for exactly that.
anyhow::Error can hold any error type. Its companion anyhow::Result<T> is a type alias for Result<T, anyhow::Error>. You can attach contextual information at every level of the call stack using .context() or .with_context():
use anyhow::{Context, Result};
use std::fs;
fn load_config() -> Result<String> {
let content = fs::read_to_string("config.toml")
.context("failed to read config file")?;
Ok(content)
}
fn main() -> Result<()> {
let config = load_config().context("startup failed while loading configuration")?;
println!("{config}");
Ok(())
}
If config.toml does not exist, the output is a chain of messages:
Error: startup failed while loading configuration
Caused by:
0: failed to read config file
1: No such file or directory (os error 2)
The original std::io::Error is preserved as the deepest cause. Any Result<T, E> where E: Display + Debug + Send + Sync + 'static can be converted into anyhow::Error automatically, so the ? operator works without manual mapping.
anyhow Is for Binaries, Not Libraries:
Because anyhow::Error is an opaque type, a downstream caller cannot match on the specific error variant. That is fine for an application's main() function, but it is a poor choice for public library APIs. Libraries should expose typed error enums so their callers can make decisions.
The From Trait and Seamless Propagation
The glue that makes idiomatic error types work with the ? operator is the From trait. When you write let x = fallible_fn()?;, the compiler looks for From<E> for YourErrorType, where E is the error type of fallible_fn. If it exists, the conversion happens automatically.
Without automatic conversion you would need to write .map_err(YourErrorType::Variant) everywhere, which defeats the purpose of ?. The idiomatic approach is to implement From for every inner error type your enum wraps. thiserror's #[from] generates these implementations for you. If you are not using thiserror, you can write them by hand:
impl From<std::num::ParseIntError> for ConfigError {
fn from(e: std::num::ParseIntError) -> Self {
ConfigError::ParseError(e)
}
}
With this implementation, ? converts ParseIntError into ConfigError exactly as the derive macro would.
Choosing Between thiserror and anyhow
The decision is not about which one is better in absolute terms — it is about which role your code plays.
| Crate | Primary use case | Error type |
|---|---|---|
thiserror | Library crates that expose typed errors | Enum with specific variants |
anyhow | Application binaries that need rich context chains | Opaque anyhow::Error |
A library that uses anyhow robs its callers of the ability to inspect errors. An application that uses only thiserror can become tedious if every module defines its own error enum and conversion logic just to bubble errors up to main. Many Rust projects use both: thiserror in the library layer, and anyhow in the binary that consumes those libraries.
Don't Mix Crate Roles:
If you expose an error type in your crate's public API, use thiserror (or a hand-written enum). Reserve anyhow for the top-level main() or narrowly scoped internal functions that will not be called from outside the crate.
Common Misconceptions and Mistakes
Overusing Box<dyn Error>
Box<dyn std::error::Error> is a type that can hold any error. It is sometimes used as a quick way to unify multiple error types without defining an enum. However, it erases type information just like String does: callers cannot match on the concrete error, and the cost is a heap allocation for every error. Use a typed enum or anyhow::Error (which provides better Display output and context methods) instead.
Forgetting to Implement Error
A type can be used as the E in Result<T, E> without implementing Error, but then it cannot participate in the wider error ecosystem. It will not work with anyhow, Box<dyn Error>, or code that expects Error bounds. Always implement Error for custom error types.
Wrapping Every Error Individually Without Conversion
If your enum has variants for each error source, implement From for them. Without it, the ? operator will not convert, and you will be forced to write .map_err repeatedly. The ergonomic difference is large:
// Without From — repetitive
let file = File::open(path).map_err(MyError::Io)?;
let port: u16 = raw.parse().map_err(MyError::Parse)?;
// With From implementations — invisible conversion
let file = File::open(path)?;
let port: u16 = raw.parse()?;
A Worked Example: Config Loading Tool
The following example shows how thiserror and anyhow fit together in a small project. A library function defines a typed error, and the binary uses anyhow to handle everything at the top level.
// In the library (or a module treated as the core logic)
use thiserror::Error;
#[derive(Error, Debug)]
pub enum LoadError {
#[error("failed to read configuration: {0}")]
Io(#[from] std::io::Error),
#[error("invalid port number: {0}")]
ParsePort(#[from] std::num::ParseIntError),
}
pub struct Config {
pub port: u16,
}
pub fn load_config(path: &str) -> Result<Config, LoadError> {
let content = std::fs::read_to_string(path)?; // io::Error → LoadError
let port: u16 = content.trim().parse()?; // ParseIntError → LoadError
Ok(Config { port })
}
// In the binary (main.rs)
use anyhow::{Context, Result};
use config::{load_config, Config};
fn main() -> Result<()> {
let config = load_config("server.config")
.context("failed to load server configuration")?;
println!("Server starting on port {}", config.port);
Ok(())
}
If server.config contains the text twelve, the output is:
Error: failed to load server configuration
Caused by:
0: invalid port number: invalid digit found in string
1: invalid digit found in string
The library error (LoadError) is wrapped in anyhow::Error with context. The user sees a clear chain, and the library callers who need programmatic access to LoadError can still match on it — provided they do not convert to anyhow too early.
Summary
Idiomatic error types in Rust are not a luxury — they are the mechanism that makes error handling composable, greppable, and safe. By using an enum that implements Error, you let callers distinguish failure modes without fragile string matching. By implementing From for inner error types, the ? operator does all the mechanical work. The thiserror crate eliminates the boilerplate of writing Display and From by hand, making it the standard choice for library error types. In application code, anyhow adds context to every layer while keeping the original cause reachable.