Rust 35 🦀 Custom Error Types and the std::error::Error Trait
The Result<T, E> carries an error type. The error type can be a String, a library error like io::Error, or a custom type the application defines. A custom error type is the tool for the applications that have their own failure modes: a validation failure, a configuration failure, a domain-specific failure that the standard library does not model. The std::error::Error trait is the contract that unifies the error types and enables the ? operator to convert between them.
This chapter covers the custom error types and the Error trait in full. You will learn the trait’s requirements, the Display and Debug implementations, the source method for the error chain, the From implementations that make the ? operator work, the Box<dyn Error> type for the simple unification, and the crates—thiserror and anyhow—that automate the boilerplate.
Key point: The std::error::Error trait requires Debug and Display. The Debug is derived; the Display is implemented. The source method returns the underlying error, which forms a chain. The From implementations convert the other error types to the custom type, and the ? operator uses the From to convert automatically. The Box<dyn Error> implements From for every error type, which makes it the simple unification. The thiserror crate generates the boilerplate; the anyhow crate provides the dynamic error type for the applications.
Why custom error types matter
The String problem. The simplest error type is String. A function returns Result<T, String>, and the error is a message. The approach works for the small programs, but it loses the structure. The caller cannot match on the error variant, cannot access the fields, and cannot distinguish the cases. The String is the error message, not the error type.
The library error problem. The library errors—io::Error, ParseIntError, ParseError—are specific to the operation that produced them. A function that reads a file and parses the contents has two error types. The function’s signature must choose one, or unify them. The custom error type is the unification.
The domain problem. The application has its own failure modes. A user is not found, a permission is denied, a validation fails, a resource is exhausted. These are not the library errors; they are the domain errors. The custom error type models them, and the caller can match on the variants.
The trait problem. The Error trait is the contract that the error types implement. The Box<dyn Error> accepts any type that implements the trait. The ? operator uses the From trait to convert the errors. The Error trait is the mechanism that makes the heterogeneous errors work together.
The chain problem. An error is often caused by another error. The file open fails because the directory does not exist. The parse fails because the input is malformed. The source method returns the underlying error, and the chain is the sequence of causes. The Error trait provides the chain, and the Display implementation can print it.
The boilerplate problem. The custom error type requires the Debug, Display, Error, and From implementations. The boilerplate is repetitive. The thiserror crate generates it from the attributes. The anyhow crate provides the anyhow::Error type for the applications that do not need the specific variants.
a. The Error trait and the custom type
The std::error::Error trait requires Debug and Display.
use std::fmt;
#[derive(Debug)]
enum AppError {
NotFound(String),
PermissionDenied(String),
Validation(String),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
AppError::NotFound(what) => write!(f, "not found: {}", what),
AppError::PermissionDenied(action) => write!(f, "permission denied: {}", action),
AppError::Validation(msg) => write!(f, "validation failed: {}", msg),
}
}
}
impl std::error::Error for AppError {}
The AppError derives Debug and implements Display. The Error trait is implemented with an empty block because the default methods are sufficient. The Error trait has a blanket implementation for the types that implement Debug and Display, so the explicit impl is optional in modern Rust.
The Error trait has the source method.
impl std::error::Error for AppError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
AppError::NotFound(_) => None,
AppError::PermissionDenied(_) => None,
AppError::Validation(_) => None,
}
}
}
The source returns the underlying error, or None if there is none. The default implementation returns None, and the explicit implementation is for the errors that wrap another error.
The From implementations convert the other error types.
use std::io;
use std::num::ParseIntError;
impl From<io::Error> for AppError {
fn from(e: io::Error) -> Self {
AppError::Validation(format!("IO error: {}", e))
}
}
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> Self {
AppError::Validation(format!("parse error: {}", e))
}
}
The From implementations convert the io::Error and the ParseIntError to the AppError. The conversion is the mechanism that makes the ? operator work.
The function that uses the custom error.
fn read_number(path: &str) -> Result<i32, AppError> {
let contents = std::fs::read_to_string(path)?; // converts io::Error
let number = contents.trim().parse::<i32>()?; // converts ParseIntError
Ok(number)
}
The ? on the read_to_string converts the io::Error to the AppError. The ? on the parse converts the ParseIntError. The function returns Result<i32, AppError>.
The caller matches on the variants.
match read_number("file.txt") {
Ok(n) => println!("Number: {}", n),
Err(AppError::NotFound(what)) => println!("Not found: {}", what),
Err(AppError::PermissionDenied(action)) => println!("Denied: {}", action),
Err(AppError::Validation(msg)) => println!("Invalid: {}", msg),
}
The match handles every variant. The caller can distinguish the cases and respond differently.
b. The Box and the error chain
The Box<dyn Error> is the dynamic error type. It accepts any type that implements Error.
fn read_number(path: &str) -> Result<i32, Box<dyn std::error::Error>> {
let contents = std::fs::read_to_string(path)?;
let number = contents.trim().parse::<i32>()?;
Ok(number)
}
The Box<dyn Error> implements From<E> for E: Error + 'static. The ? converts the io::Error and the ParseIntError to the boxed type. The signature is simpler than the custom error type.
The Box<dyn Error> is the return type for main.
fn main() -> Result<(), Box<dyn std::error::Error>> {
let contents = std::fs::read_to_string("file.txt")?;
println!("{}", contents);
Ok(())
}
The ? in main propagates the error to the runtime, which prints it and exits with a non-zero status.
The Box<dyn Error> can be downcast to the original type.
let error: Box<dyn Error> = Box::new(io::Error::new(io::ErrorKind::NotFound, "missing"));
if let Some(io_error) = error.downcast_ref::<io::Error>() {
println!("IO error: {}", io_error);
}
The downcast_ref recovers the original type. The downcast consumes the box and returns the owned value.
The error chain is the sequence of the source values. The chain method is not on the Error trait itself, but the source method is, and the chain is built by calling source repeatedly.
fn print_chain(error: &dyn std::error::Error) {
println!("{}", error);
let mut source = error.source();
while let Some(e) = source {
println!(" caused by: {}", e);
source = e.source();
}
}
The print_chain prints the error and each of its sources. The chain is the sequence of the causes.
The Error trait has the provide method in the nightly Rust for the error context, but the stable version uses the source method.
The anyhow crate provides the anyhow::Error type, which is the dynamic error type with the context.
use anyhow::{Context, Result};
fn read_number(path: &str) -> Result<i32> {
let contents = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path))?;
let number = contents.trim().parse::<i32>()
.with_context(|| format!("failed to parse {}", contents))?;
Ok(number)
}
The anyhow::Result<T> is the alias for Result<T, anyhow::Error>. The context method adds the context to the error. The with_context takes a closure for the lazy context.
The anyhow error prints the context chain.
fn main() -> anyhow::Result<()> {
let n = read_number("file.txt")?;
println!("{}", n);
Ok(())
}
The output shows the context chain, which is the message the developer provided at each step.
c. The thiserror and anyhow crates
The thiserror crate generates the Display, Error, and From implementations from the attributes.
use thiserror::Error;
#[derive(Debug, Error)]
enum AppError {
#[error("not found: {0}")]
NotFound(String),
#[error("permission denied: {0}")]
PermissionDenied(String),
#[error("validation failed: {0}")]
Validation(String),
#[error("IO error")]
Io(#[from] std::io::Error),
#[error("parse error")]
Parse(#[from] std::num::ParseIntError),
}
The #[derive(Error)] generates the Error implementation. The #[error("...")] attribute generates the Display implementation. The #[from] attribute generates the From implementation for the variant. The result is the same as the manual implementation, but the boilerplate is gone.
The #[source] attribute marks the field as the source of the error.
#[derive(Debug, Error)]
enum AppError {
#[error("request failed")]
Request(#[source] reqwest::Error),
}
The #[source] generates the source method that returns the field.
The #[error(transparent)] attribute delegates the Display and source to the field.
#[derive(Debug, Error)]
enum AppError {
#[error(transparent)]
Io(#[from] std::io::Error),
}
The transparent variant prints the underlying error’s message and returns the underlying error as the source.
The thiserror crate is for the libraries. The custom error type is the public API, and the thiserror generates the boilerplate.
The anyhow crate is for the applications. The anyhow::Error is the dynamic error type, and the context is the message the developer adds at each step.
use anyhow::{bail, Context, Result};
fn validate_username(s: &str) -> Result<()> {
if s.is_empty() {
bail!("username is empty");
}
if s.len() < 3 {
bail!("username is shorter than 3 characters");
}
Ok(())
}
The bail! macro returns the error with the message. The anyhow::Result<()> is the return type.
The context method adds the context.
fn read_config(path: &str) -> Result<Config> {
let contents = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path))?;
let config = toml::from_str(&contents)
.with_context(|| format!("failed to parse {}", path))?;
Ok(config)
}
The with_context adds the message to the error. The chain shows the context at each step.
The choice between the two:
| Crate | Use |
|---|---|
thiserror | Libraries with the specific error types |
anyhow | Applications with the dynamic error type |
The thiserror and anyhow can be used together. The library defines the error type with thiserror, and the application uses anyhow for the top-level handling.
The anyhow::Error can be converted to the Box<dyn Error>.
fn f() -> Result<(), Box<dyn std::error::Error>> {
let result = application_code()?; // anyhow::Error
Ok(result)
}
The anyhow::Error implements Error, so the ? converts it to the boxed type.
Complete Example Session
// ============================================
// PART 1: BASIC CUSTOM ERROR
// ============================================
use std::fmt;
#[derive(Debug)]
enum AppError {
NotFound(String),
PermissionDenied(String),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
AppError::NotFound(what) => write!(f, "not found: {}", what),
AppError::PermissionDenied(action) => write!(f, "denied: {}", action),
}
}
}
impl std::error::Error for AppError {}
// ============================================
// PART 2: FROM IMPLEMENTATIONS
// ============================================
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self {
AppError::NotFound(format!("IO: {}", e))
}
}
// ============================================
// PART 3: ? WITH CUSTOM ERROR
// ============================================
fn read_file(path: &str) -> Result<String, AppError> {
let contents = std::fs::read_to_string(path)?;
Ok(contents)
}
// ============================================
// PART 4: MATCH ON VARIANTS
// ============================================
match read_file("file.txt") {
Ok(c) => println!("{}", c),
Err(AppError::NotFound(what)) => println!("Not found: {}", what),
Err(AppError::PermissionDenied(action)) => println!("Denied: {}", action),
}
// ============================================
// PART 5: BOX<DYN ERROR>
// ============================================
fn read_number(path: &str) -> Result<i32, Box<dyn std::error::Error>> {
let contents = std::fs::read_to_string(path)?;
let number = contents.trim().parse::<i32>()?;
Ok(number)
}
// ============================================
// PART 6: ERROR CHAIN
// ============================================
fn print_chain(error: &dyn std::error::Error) {
println!("{}", error);
let mut source = error.source();
while let Some(e) = source {
println!(" caused by: {}", e);
source = e.source();
}
}
// ============================================
// PART 7: THISERROR
// ============================================
use thiserror::Error;
#[derive(Debug, Error)]
enum AppError {
#[error("not found: {0}")]
NotFound(String),
#[error("IO error")]
Io(#[from] std::io::Error),
#[error("parse error")]
Parse(#[from] std::num::ParseIntError),
}
// ============================================
// PART 8: ANYHOW
// ============================================
use anyhow::{Context, Result};
fn read_number(path: &str) -> Result<i32> {
let contents = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path))?;
let number = contents.trim().parse::<i32>()
.with_context(|| format!("failed to parse {}", contents))?;
Ok(number)
}
// ============================================
// PART 9: BAIL
// ============================================
use anyhow::{bail, Result};
fn validate(s: &str) -> Result<()> {
if s.is_empty() {
bail!("input is empty");
}
Ok(())
}
// ============================================
// PART 10: TRANSPARENT VARIANT
// ============================================
#[derive(Debug, Error)]
enum AppError {
#[error(transparent)]
Io(#[from] std::io::Error),
}
The ten parts covered the basic custom error, the From implementations, the ? with the custom error, the match on the variants, the Box<dyn Error>, the error chain, thiserror, anyhow, bail!, and the transparent variant.
Quick Reference
Error Trait Requirements
| Requirement | Source |
|---|---|
Debug | Derived |
Display | Implemented |
source | Default None |
Error impl | Blanket or explicit |
Custom Error Steps
| Step | Code |
|---|---|
| Define enum | enum AppError { ... } |
| Derive Debug | #[derive(Debug)] |
| Implement Display | impl fmt::Display for AppError |
| Implement Error | impl std::error::Error for AppError |
| Implement From | impl From<OtherError> for AppError |
Box and Dynamic
| Type | Use |
|---|---|
Box<dyn Error> | Dynamic error, simple unification |
anyhow::Error | Application error with context |
thiserror::Error | Library error with variants |
thiserror Attributes
| Attribute | Generates |
|---|---|
#[derive(Error)] | Error impl |
#[error("...")] | Display impl |
#[from] | From impl |
#[source] | source method |
#[error(transparent)] | Delegate to field |
Error Chain
| Method | Returns |
|---|---|
source() | Option<&dyn Error> |
chain() | Iterator of errors (via itertools) |
downcast_ref::<T>() | Option<&T> |
downcast::<T>() | Result<Box<T>, Box<dyn Error>> |
Best Practices
✅ Do This:
// Derive Debug, implement Display and Error
#[derive(Debug)]
enum AppError { } // ✅
impl fmt::Display for AppError { } // ✅
impl std::error::Error for AppError { } // ✅
// Implement From for each error type
impl From<io::Error> for AppError { } // ✅
// Use Box<dyn Error> for the simple case
fn f() -> Result<(), Box<dyn Error>> { } // ✅
// Use thiserror for the libraries
#[derive(Error)]
enum AppError { #[error("...")] Variant, #[from] Io(io::Error) } // ✅
// Use anyhow for the applications
fn f() -> anyhow::Result<()> { } // ✅
// Add context with anyhow
.with_context(|| format!("failed to read {}", path))? // ✅
// Print the chain
let mut source = error.source();
while let Some(e) = source { source = e.source(); } // ✅
❌ Don’t Do This:
// Don't use String for the error types in a library
fn f() -> Result<i32, String> { } // ⚠️
// Don't forget the Display implementation
#[derive(Debug)]
enum AppError { }
impl std::error::Error for AppError { } // requires Display // ❌
// Don't catch and discard the source
impl From<io::Error> for AppError {
fn from(e: io::Error) -> Self { AppError::Other }
} // ⚠️
// Don't panic in the library
fn f() -> Result<i32, AppError> {
panic!("not found") // ❌
}
// Don't use anyhow in a library's public API
pub fn f() -> anyhow::Result<()> { } // ⚠️
// Don't lose the error chain
match result {
Err(e) => Err(AppError::Other(e.to_string())), // ⚠️
}
// Don't implement Error without Debug and Display
struct AppError;
impl std::error::Error for AppError { } // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Error trait not satisfied | Missing Display | Implement Display |
? conversion fails | No From impl | Implement From |
| Source lost | Wrapped in new error | Use #[source] |
Library uses anyhow | Public API unstable | Use thiserror |
| Chain not printed | Only Display shown | Walk the source |
| Downcast fails | Type mismatch | Check the type |
Real-World Examples
1. Custom Enum
#[derive(Debug)]
enum AppError {
NotFound(String),
PermissionDenied(String),
}
2. Display
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
AppError::NotFound(w) => write!(f, "not found: {}", w),
AppError::PermissionDenied(a) => write!(f, "denied: {}", a),
}
}
}
3. Error Impl
impl std::error::Error for AppError {}
4. From
impl From<io::Error> for AppError {
fn from(e: io::Error) -> Self {
AppError::NotFound(format!("IO: {}", e))
}
}
5. Box
fn f() -> Result<(), Box<dyn Error>> { }
6. thiserror
#[derive(Error)]
enum AppError {
#[error("not found: {0}")]
NotFound(String),
#[error("IO error")]
Io(#[from] io::Error),
}
7. anyhow
fn f() -> anyhow::Result<()> {
let x = op().context("operation failed")?;
Ok(())
}
8. bail
fn validate(s: &str) -> Result<()> {
if s.is_empty() {
bail!("input is empty");
}
Ok(())
}
9. Transparent
#[derive(Error)]
enum AppError {
#[error(transparent)]
Io(#[from] io::Error),
}
10. Chain
fn print_chain(error: &dyn Error) {
let mut source = error.source();
while let Some(e) = source {
println!("caused by: {}", e);
source = e.source();
}
}
Visual
Error Trait Requirements
┌─────────────────────────────────────────────────────────────┐
│ std::error::Error │
│ │
│ Requires: │
│ Debug (derived) │
│ Display (implemented) │
│ │
│ Provides: │
│ source() -> Option<&dyn Error> │
│ description() (deprecated) │
│ │
│ The Error impl is optional; the blanket impl exists for │
│ the types that satisfy Debug + Display. │
│ │
└─────────────────────────────────────────────────────────────┘
Error Chain
┌─────────────────────────────────────────────────────────────┐
│ AppError │
│ │ │
│ └── source ──▶ io::Error │
│ │ │
│ └── source ──▶ os error │
│ │
│ The chain is the sequence of the causes. │
│ The Display prints the message. │
│ The source walks the chain. │
│ │
└─────────────────────────────────────────────────────────────┘
From and the ? Operator
┌─────────────────────────────────────────────────────────────┐
│ fn read(path: &str) -> Result<i32, AppError> { │
│ let c = fs::read_to_string(path)?; // io::Error │
│ let n = c.parse::<i32>()?; // ParseIntError │
│ Ok(n) │
│ } │
│ │
│ The ? uses the From: │
│ io::Error → AppError │
│ ParseIntError → AppError │
│ │
│ Without the From, the ? does not compile. │
│ │
└─────────────────────────────────────────────────────────────┘
thiserror vs anyhow
┌─────────────────────────────────────────────────────────────┐
│ THISERROR (libraries) │
│ │
│ Specific variants │
│ Matchable by the caller │
│ Stable public API │
│ Generates Display, Error, From │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ ANYHOW (applications) │
│ │
│ Dynamic error type │
│ Context added at each step │
│ Chain of the context │
│ Not for the public API │
│ │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Trait | std::error::Error |
| Requires | Debug + Display |
source | Option<&dyn Error> |
From | Enables ? conversion |
| Dynamic | Box<dyn Error> |
| Chain | source walk |
| Library | thiserror |
| Application | anyhow |
| thiserror derive | #[derive(Error)] |
| thiserror attrs | #[error], #[from], #[source] |
| anyhow context | .context(), .with_context() |
| anyhow bail | bail!(msg) |
Key takeaways:
- The
Errortrait requiresDebugandDisplay. TheDebugis derived, and theDisplayis implemented. TheErrorimplementation itself is optional because of the blanket implementation for the types that satisfyDebug + Display. - The
sourcemethod forms the error chain. It returns the underlying error, orNoneif there is none. The chain is walked by callingsourcerepeatedly. - The
Fromimplementations make the?operator work. The?converts the error type via theFromtrait. The custom error type implementsFromfor each of the error types it wraps. - The
Box<dyn Error>is the dynamic error type. It implementsFromfor every error type, which makes it the simple unification. It is the return type formainand for the functions that do not need the specific variants. - The
thiserrorcrate generates the boilerplate. The#[derive(Error)]and the#[error],#[from], and#[source]attributes generate theDisplay,Error, andFromimplementations. - The
anyhowcrate is for the applications. Theanyhow::Erroris the dynamic error type, and thecontextmethod adds the message at each step. Thebail!macro returns the error with the message. - The
thiserrorandanyhoware complementary. The library defines the error type withthiserror, and the application usesanyhowfor the top-level handling. The two compose.
Remember: The custom error type is the tool for the applications with their own failure modes. The Error trait is the contract that unifies the error types. The From implementations make the ? operator work. The Box<dyn Error> is the simple unification, and the thiserror and anyhow crates automate the boilerplate. Use the custom error types for the libraries, and use anyhow for the applications. And remember the chain: the source method walks the causes, and the context is the message at each step. The error is not just the message; it is the type, the source, and the context. The custom error type is how the type and the source are carried, and the Display is how the message is printed.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!