Custom Error Types
As your Rust project grows, io::Error and String stop being adequate error types. You need errors that are specific to your domain, carry structured information, and convert between each other cleanly. The ecosystem has settled on two crates that cover virtually every use case: thiserror for libraries and anyhow for applications.
The Problem with Ad Hoc Errors
// This works but it is a dead end:
fn load_config(path: &str) -> Result<Config, String> {
let raw = std::fs::read_to_string(path)
.map_err(|e| format!("failed to read {}: {}", path, e))?;
let config: Config = toml::from_str(&raw)
.map_err(|e| format!("failed to parse {}: {}", path, e))?;
Ok(config)
}
String errors cannot be programmatically matched. Callers cannot distinguish "file not found" from "parse error." They can only show the message to a human. For an application, that might be fine. For a library, it is not.
thiserror for Library Errors
thiserror is a derive macro that generates Error and Display implementations for your custom error type:
use std::io;
use thiserror::Error;
#[derive(Error, Debug)]
enum ConfigError {
#[error("failed to read config file '{path}': {source}")]
ReadFile {
path: String,
source: io::Error,
},
#[error("failed to parse config: {0}")]
Parse(String),
#[error("missing required field: {0}")]
MissingField(String),
#[error("invalid port {port}: must be between 1 and 65535")]
InvalidPort { port: u32 },
}
struct Config {
host: String,
port: u16,
}
fn load_config(path: &str) -> Result<Config, ConfigError> {
let raw = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
path: path.to_string(),
source: e,
})?;
let host = raw.lines()
.find(|l| l.starts_with("host="))
.map(|l| l.trim_start_matches("host=").to_string())
.ok_or_else(|| ConfigError::MissingField("host".to_string()))?;
let port_str = raw.lines()
.find(|l| l.starts_with("port="))
.map(|l| l.trim_start_matches("port="))
.ok_or_else(|| ConfigError::MissingField("port".to_string()))?;
let port: u32 = port_str.parse()
.map_err(|_| ConfigError::Parse(format!("'{}' is not a number", port_str)))?;
if port == 0 || port > 65535 {
return Err(ConfigError::InvalidPort { port });
}
Ok(Config {
host,
port: port as u16,
})
}
fn main() {
match load_config("app.conf") {
Ok(config) => println!("{}:{}", config.host, config.port),
Err(e) => eprintln!("Error: {}", e),
}
}
Error: failed to read config file 'app.conf': No such file or directory (os error 2)
thiserror generates the Display implementation from the #[error("...")] attribute and implements std::error::Error automatically. The {source} placeholder chains the underlying error.
The From Trait for Automatic Conversion
thiserror can generate From implementations so ? converts errors automatically:
use std::io;
use std::num::ParseIntError;
use thiserror::Error;
#[derive(Error, Debug)]
enum AppError {
#[error("I/O error: {0}")]
Io(#[from] io::Error),
#[error("parse error: {0}")]
Parse(#[from] ParseIntError),
#[error("{0}")]
Custom(String),
}
fn read_port(path: &str) -> Result<u16, AppError> {
let contents = std::fs::read_to_string(path)?; // io::Error -> AppError::Io
let port: u16 = contents.trim().parse()?; // ParseIntError -> AppError::Parse
Ok(port)
}
fn main() {
match read_port("port.txt") {
Ok(port) => println!("Port: {}", port),
Err(e) => eprintln!("Error: {}", e),
}
}
Error: I/O error: No such file or directory (os error 2)
The #[from] attribute on a variant generates impl From<io::Error> for AppError. This lets ? convert errors automatically without map_err.
anyhow for Application Errors
Libraries need structured error types so callers can match on them. Applications usually just need to propagate errors and add context. anyhow is built for this:
use anyhow::{Context, Result};
use std::fs;
fn load_config(path: &str) -> Result<String> {
let contents = fs::read_to_string(path)
.with_context(|| format!("failed to read config from {}", path))?;
Ok(contents)
}
fn start_server() -> Result<()> {
let config = load_config("/etc/myapp/config.toml")?;
let port: u16 = config.lines()
.find(|l| l.starts_with("port="))
.context("config missing 'port' field")?
.trim_start_matches("port=")
.parse()
.context("invalid port number")?;
println!("Starting server on port {}", port);
Ok(())
}
fn main() {
if let Err(e) = start_server() {
eprintln!("Error: {:#}", e);
std::process::exit(1);
}
}
Error: failed to read config from /etc/myapp/config.toml: No such file or directory (os error 2)
anyhow::Result<T> is shorthand for Result<T, anyhow::Error>. The context and with_context methods wrap errors with additional information. The {:#} format flag shows the full error chain.
When to Use Which
| Situation | Use |
|---|---|
| Library crate | thiserror — callers need structured errors they can match on |
| Application binary | anyhow — you just need to report errors to humans |
| Shared internal crate | thiserror — other crates in your workspace depend on it |
| Quick prototype | anyhow — minimal boilerplate, add structure later |
| Public API | thiserror — your error types are part of your contract |
The two crates work well together. A library defines errors with thiserror. An application wraps them with anyhow::Context:
// In a library crate:
use thiserror::Error;
#[derive(Error, Debug)]
pub enum DatabaseError {
#[error("connection failed: {0}")]
Connection(String),
#[error("query failed: {0}")]
Query(String),
}
// In the application:
use anyhow::{Context, Result};
fn run_migration() -> Result<()> {
let db = connect_database()
.context("failed to connect during migration")?;
db.execute("CREATE TABLE users (id INT)")
.context("migration query failed")?;
Ok(())
}
The Error Type Hierarchy
A well-designed error hierarchy maps to your domain:
use thiserror::Error;
use std::io;
#[derive(Error, Debug)]
pub enum StorageError {
#[error("file I/O error: {0}")]
Io(#[from] io::Error),
#[error("data corruption in {file}: {details}")]
Corruption { file: String, details: String },
#[error("storage full: need {needed} bytes, have {available}")]
Full { needed: u64, available: u64 },
}
#[derive(Error, Debug)]
pub enum ServiceError {
#[error("storage error: {0}")]
Storage(#[from] StorageError),
#[error("authentication failed for user {user}")]
Auth { user: String },
#[error("rate limited: retry after {retry_after_secs}s")]
RateLimited { retry_after_secs: u64 },
}
Lower-level errors convert into higher-level ones via From. Each layer adds domain-specific context.
Implementing Error Manually
Sometimes you need more control than thiserror provides:
use std::fmt;
#[derive(Debug)]
struct ValidationError {
field: String,
message: String,
value: String,
}
impl fmt::Display for ValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "validation failed for '{}': {} (got '{}')",
self.field, self.message, self.value)
}
}
impl std::error::Error for ValidationError {}
fn validate_email(email: &str) -> Result<(), ValidationError> {
if !email.contains('@') {
return Err(ValidationError {
field: "email".to_string(),
message: "must contain @".to_string(),
value: email.to_string(),
});
}
Ok(())
}
fn main() {
match validate_email("not-an-email") {
Ok(()) => println!("Valid"),
Err(e) => println!("Error: {}", e),
}
}
Error: validation failed for 'email': must contain @ (got 'not-an-email')
This is what thiserror generates for you. Write it manually only when you need custom behavior in the Display or Error implementations.
Downcasting with anyhow
anyhow errors can be downcast to their original types:
use anyhow::Result;
use std::io;
fn might_fail() -> Result<()> {
let _ = std::fs::read_to_string("missing.txt")?;
Ok(())
}
fn main() {
if let Err(e) = might_fail() {
if let Some(io_err) = e.downcast_ref::<io::Error>() {
match io_err.kind() {
io::ErrorKind::NotFound => println!("File not found, using defaults"),
io::ErrorKind::PermissionDenied => println!("Permission denied"),
_ => println!("I/O error: {}", io_err),
}
} else {
println!("Other error: {}", e);
}
}
}
File not found, using defaults
Common Pitfalls
- Using
Stringas an error type in libraries. Callers cannot match on string content. Use a proper enum. - Creating too many error variants. Each variant should represent a distinct recoverable scenario. If callers will never match on a variant, it might not need to be separate.
- Forgetting
#[from]when conversion is straightforward. If your error enum wraps another error type directly,#[from]saves boilerplate and enables?. - Using
anyhowin library crates. Libraries should expose typed errors. Applications consume libraries and wrap their errors inanyhow. - Not including enough context. "parse error" is useless. "failed to parse port from /etc/myapp/config.toml: invalid digit" tells you exactly what happened and where.
- Implementing
Fromfor overlapping types. If two variants both use#[from]with the same source type, the compiler cannot decide which conversion to use.
Key Takeaways
- Use
thiserrorfor library error types: structured, matchable, withFromconversions via#[from]. - Use
anyhowfor application error handling: easy propagation, rich context, minimal boilerplate. - The
Fromtrait enables automatic error conversion with?.thiserror's#[from]generates it for you. - Error hierarchies should mirror your domain: storage errors, service errors, validation errors.
- Always add context to errors. "What failed" and "why" should be answerable from the error message.
- The two crates complement each other: libraries define errors with
thiserror, applications wrap them withanyhow.