Custom Error Types
Rust's standard io::Error and ParseIntError are fine for their respective domains, but real applications inevitably encounter situations that those types cannot describe. Custom error types give you better error messages, type-safe error handling, and an ergonomic API for callers who need to react to specific failure modes. This page walks through every layer of building custom errors in Rust — from the raw trait implementations up to the popular thiserror and anyhow crates.
Why Custom Errors?
Descriptive messages —
AppError::NotFound("user_id=42")is clearer than a generic stringType safety — callers can match on variants to handle different failure modes differently
Ergonomics — implement
Fromso the?operator converts lower-level errors automaticallyDocumentation — each variant is a documented, named thing in your public API
Composability — wrap third-party errors to add context without losing the original cause
The Error Trait
To be a proper Rust error, a type must implement two traits: 1. std::fmt::Display — the human-readable message shown to users 2. std::fmt::Debug — the developer-facing representation (usually derived) std::error::Error sits on top of both and adds an optional source() method that returns the underlying cause. The full requirement is:
pub trait Error: Debug + Display {
// Returns the lower-level source of this error, if any.
fn source(&self) -> Option<&(dyn Error + 'static)> {
None
}
}This means to implement Error you must first implement both Display and Debug. You get Debug for free with #[derive(Debug)].
A Simple Custom Error Enum
Start with an enum whose variants describe each distinct failure mode. Here is an error type for a hypothetical user-management library:
use std::fmt;
use std::num::ParseIntError;
#[derive(Debug)]
pub enum AppError {
/// The requested resource was not found.
NotFound(String),
/// An integer could not be parsed from a string.
ParseError(ParseIntError),
/// An I/O operation failed.
IoError(std::io::Error),
/// A generic message for one-off situations.
Other(String),
}Implementing Display
Display is the message a user sees. Write it in clear, lowercase, present-tense English. Do not include "Error:" — the caller's error-reporting code typically adds that.
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::NotFound(id) =>
write!(f, "resource not found: {}", id),
AppError::ParseError(e) =>
write!(f, "parse error: {}", e),
AppError::IoError(e) =>
write!(f, "I/O error: {}", e),
AppError::Other(msg) =>
write!(f, "{}", msg),
}
}
}Implementing Error
Once Debug and Display are in place, implementing std::error::Error is often just a trait declaration — but you should also implement source() for variants that wrap another error, so callers can inspect the full error chain.
impl std::error::Error for AppError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
AppError::ParseError(e) => Some(e),
AppError::IoError(e) => Some(e),
_ => None,
}
}
}Implementing From for Automatic Conversion
The ? operator calls From::from(e) on errors before returning them. Implement From for each inner error type and ? will convert them to your AppError automatically — no .map_err(AppError::IoError) boilerplate needed.
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self {
AppError::IoError(e)
}
}
impl From<std::num::ParseIntError> for AppError {
fn from(e: std::num::ParseIntError) -> Self {
AppError::ParseError(e)
}
}// Now ? converts errors automatically — no map_err needed
fn load_user_id(path: &str) -> Result<u32, AppError> {
let text = std::fs::read_to_string(path)?; // io::Error -> AppError::IoError
let id: u32 = text.trim().parse()?; // ParseIntError -> AppError::ParseError
Ok(id)
}
fn find_user(id: u32) -> Result<String, AppError> {
if id == 0 {
return Err(AppError::NotFound(format!("user id={}", id)));
}
Ok(format!("User#{}", id))
}
fn main() {
match load_user_id("user.txt").and_then(|id| find_user(id)) {
Ok(name) => println!("found: {}", name),
Err(AppError::NotFound(msg)) => eprintln!("not found: {}", msg),
Err(AppError::IoError(e)) => eprintln!("I/O problem: {}", e),
Err(e) => eprintln!("error: {}", e),
}
}The thiserror Crate
Writing Display, Error, and multiple From implementations by hand is correct but repetitive. The thiserror crate provides a #[derive(Error)] macro that generates all of it from annotations on your enum. It is the community standard for library error types.
# Cargo.toml [dependencies] thiserror = "1"
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("resource not found: {0}")]
NotFound(String),
#[error("parse error: {0}")]
ParseError(#[from] std::num::ParseIntError),
#[error("I/O error: {0}")]
IoError(#[from] std::io::Error),
#[error("value {value} out of range 1..={max}")]
OutOfRange { value: u32, max: u32 },
}What thiserror generates for you: - Display from the
#[error("...")] strings (with {0} / {field} interpolation) -
std::error::Error with source() pointing at wrapped inner errors -
From<InnerError> for AppError for every field annotated with
#[from]
fn load_and_validate(path: &str) -> Result<u32, AppError> {
let text = std::fs::read_to_string(path)?; // io::Error -> AppError::IoError via #[from]
let n: u32 = text.trim().parse()?; // ParseIntError -> AppError::ParseError via #[from]
if n == 0 || n > 9999 {
return Err(AppError::OutOfRange { value: n, max: 9999 });
}
Ok(n)
}
fn main() {
match load_and_validate("id.txt") {
Ok(id) => println!("id: {}", id),
Err(AppError::NotFound(s)) => eprintln!("not found: {}", s),
Err(AppError::OutOfRange { value, max }) =>
eprintln!("out of range: {} (max {})", value, max),
Err(e) => eprintln!("error: {}", e),
}
}The anyhow Crate
anyhow takes a different philosophy: instead of a typed error enum, it gives you a single anyhow::Error type that can hold any error. It trades away the ability to match on specific variants in exchange for zero-boilerplate propagation and rich error chains with context messages. It is the community standard for application code (binaries, CLIs, scripts).
# Cargo.toml [dependencies] anyhow = "1"
use anyhow::{bail, ensure, Context, Result};
fn load_config(path: &str) -> Result<(String, u16)> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("failed to open config file: {}", path))?;
let mut host = String::from("localhost");
let mut port: u16 = 8080;
for line in text.lines() {
if let Some(h) = line.strip_prefix("host=") {
host = h.to_string();
} else if let Some(p) = line.strip_prefix("port=") {
port = p.parse()
.with_context(|| format!("invalid port: {:?}", p))?;
}
}
// bail! is shorthand for return Err(anyhow::anyhow!("..."))
ensure!(!host.is_empty(), "host must not be empty");
ensure!(port > 0, "port must be greater than 0");
Ok((host, port))
}
fn main() -> Result<()> {
let (host, port) = load_config("server.conf")?;
println!("server: {}:{}", host, port);
Ok(())
}Key anyhow helpers: - .context("msg") / .with_context(|| ...) — attach a message to any error - bail!("msg") — shorthand for return Err(anyhow!("msg")) - ensure!(condition, "msg") — like assert! but returns Err instead of panicking - anyhow!("msg") — create an anyhow::Error from a string
Error Wrapping and Context
A common pattern is to add a human-readable layer of context as an error propagates upward, so the final error message reads like a story: "loading config file: opening port setting: invalid number: cannot parse 'abc' as an integer".
use anyhow::{Context, Result};
fn parse_port(s: &str) -> Result<u16> {
s.parse::<u16>().context("port must be a number between 1 and 65535")
}
fn load_port(path: &str) -> Result<u16> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("opening {}", path))?;
parse_port(text.trim())
.with_context(|| format!("parsing port from {}", path))
}
fn start_server(config_path: &str) -> Result<()> {
let port = load_port(config_path)
.context("failed to load server configuration")?;
println!("listening on port {}", port);
Ok(())
}
fn main() {
if let Err(e) = start_server("config.txt") {
// Print the full error chain
eprintln!("Error: {:#}", e);
// Output (if config.txt is missing):
// Error: failed to load server configuration: opening config.txt:
// No such file or directory (os error 2)
}
}When to Use thiserror vs anyhow vs Manual
Approach | Best for | Callers can match variants? |
|---|---|---|
Manual | Full control, no dependencies | Yes |
| Library crates — typed, ergonomic error enums | Yes |
| Application / binary code — zero boilerplate, rich context | No (use downcasting) |
| Quick prototypes, | No |
A common rule of thumb in the Rust community: use thiserror in libraries, anyhow in applications. A library's callers need to match on error variants to take specific actions; an application's job is mostly to display errors and exit gracefully.
Displaying Errors to Users vs Logging for Developers
Two audiences read your errors: users and developers. Tailor the message accordingly:
Users (via stderr / UI): clear, actionable, no stack traces — "Config file not found: try running 'myapp init' first"
Developers (via logs / crash reports): full context, error chain, file and line — use
{:#}with anyhow or{:?}with DebugNever expose raw internal errors (SQL, OS codes) directly to users — wrap them in a user-facing message
use anyhow::{Context, Result};
use std::fmt;
fn run() -> Result<()> {
std::fs::read_to_string("config.txt")
.context("configuration file not found — run 'myapp init' to create one")?;
Ok(())
}
fn main() {
match run() {
Ok(()) => {}
Err(e) => {
// User-facing: outermost message only
eprintln!("Error: {}", e);
// Developer log: full chain with debug info
// In production you would send this to a log aggregator
let chain: Vec<String> = std::iter::successors(
Some(e.as_ref() as &dyn std::error::Error),
|e| e.source(),
)
.map(|e| e.to_string())
.collect();
if chain.len() > 1 {
eprintln!("Caused by:");
for (i, cause) in chain.iter().skip(1).enumerate() {
eprintln!(" {}: {}", i, cause);
}
}
std::process::exit(1);
}
}
}A Complete Library Error Type
Here is a production-style error type for a hypothetical userdb library crate. Every variant is documented, inner errors are wrapped and exposed via source(), and From implementations let ? do the heavy lifting.
//! userdb/src/error.rs
use thiserror::Error;
/// All errors that can be returned by the `userdb` crate.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum UserDbError {
/// The requested user was not found in the database.
#[error("user not found: id={id}")]
NotFound { id: u64 },
/// The provided user ID string could not be parsed as an integer.
#[error("invalid user ID {raw:?}: {source}")]
InvalidId {
raw: String,
#[source]
source: std::num::ParseIntError,
},
/// An underlying I/O error occurred while reading the database file.
#[error("database I/O error")]
Io(#[from] std::io::Error),
/// A field value exceeded its maximum allowed length.
#[error("field {field:?} too long: {len} chars (max {max})")]
FieldTooLong { field: String, len: usize, max: usize },
}
/// Convenience alias used throughout the crate.
pub type Result<T> = std::result::Result<T, UserDbError>;//! userdb/src/lib.rs
mod error;
pub use error::{UserDbError, Result};
pub fn find_user(id_str: &str) -> Result<String> {
// Parse the ID — ParseIntError converts to UserDbError::InvalidId manually
let id: u64 = id_str.parse().map_err(|e| UserDbError::InvalidId {
raw: id_str.to_string(),
source: e,
})?;
// Simulate a database read — io::Error converts automatically via #[from]
let db = std::fs::read_to_string("users.db")?;
// Search for the user
for line in db.lines() {
let mut parts = line.splitn(2, ':');
if let (Some(db_id), Some(name)) = (parts.next(), parts.next()) {
if db_id.trim() == id_str {
let name = name.trim();
if name.len() > 64 {
return Err(UserDbError::FieldTooLong {
field: "name".to_string(),
len: name.len(),
max: 64,
});
}
return Ok(name.to_string());
}
}
}
Err(UserDbError::NotFound { id })
}Summary
Task | How |
|---|---|
Define a custom error enum |
|
Human-readable message |
|
Expose the inner cause |
|
Enable |
|
Add context in app code |
|
Prevent breaking changes |
|