| |

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:

CrateUse
thiserrorLibraries with the specific error types
anyhowApplications 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

RequirementSource
DebugDerived
DisplayImplemented
sourceDefault None
Error implBlanket or explicit

Custom Error Steps

StepCode
Define enumenum AppError { ... }
Derive Debug#[derive(Debug)]
Implement Displayimpl fmt::Display for AppError
Implement Errorimpl std::error::Error for AppError
Implement Fromimpl From<OtherError> for AppError

Box and Dynamic

TypeUse
Box<dyn Error>Dynamic error, simple unification
anyhow::ErrorApplication error with context
thiserror::ErrorLibrary error with variants

thiserror Attributes

AttributeGenerates
#[derive(Error)]Error impl
#[error("...")]Display impl
#[from]From impl
#[source]source method
#[error(transparent)]Delegate to field

Error Chain

MethodReturns
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

PitfallWhy It HappensFix
Error trait not satisfiedMissing DisplayImplement Display
? conversion failsNo From implImplement From
Source lostWrapped in new errorUse #[source]
Library uses anyhowPublic API unstableUse thiserror
Chain not printedOnly Display shownWalk the source
Downcast failsType mismatchCheck 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

ItemValue
Traitstd::error::Error
RequiresDebug + Display
sourceOption<&dyn Error>
FromEnables ? conversion
DynamicBox<dyn Error>
Chainsource walk
Librarythiserror
Applicationanyhow
thiserror derive#[derive(Error)]
thiserror attrs#[error], #[from], #[source]
anyhow context.context(), .with_context()
anyhow bailbail!(msg)

Key takeaways:

  • The Error trait requires Debug and Display. The Debug is derived, and the Display is implemented. The Error implementation itself is optional because of the blanket implementation for the types that satisfy Debug + Display.
  • The source method forms the error chain. It returns the underlying error, or None if there is none. The chain is walked by calling source repeatedly.
  • The From implementations make the ? operator work. The ? converts the error type via the From trait. The custom error type implements From for each of the error types it wraps.
  • The Box<dyn Error> is the dynamic error type. It implements From for every error type, which makes it the simple unification. It is the return type for main and for the functions that do not need the specific variants.
  • The thiserror crate generates the boilerplate. The #[derive(Error)] and the #[error], #[from], and #[source] attributes generate the Display, Error, and From implementations.
  • The anyhow crate is for the applications. The anyhow::Error is the dynamic error type, and the context method adds the message at each step. The bail! macro returns the error with the message.
  • The thiserror and anyhow are complementary. The library defines the error type with thiserror, and the application uses anyhow for 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!