RustCustom Error Types

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 messagesAppError::NotFound("user_id=42") is clearer than a generic string

  • Type safety — callers can match on variants to handle different failure modes differently

  • Ergonomics — implement From so the ? operator converts lower-level errors automatically

  • Documentation — 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:

RUST
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:

RUST
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),
}
Note
Keep variant names in `PascalCase` and make their payloads as specific as possible. A `NotFound(String)` that carries the resource identifier is far more useful in logs than a bare `NotFound` variant with no context.
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.

RUST
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.

RUST
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,
        }
    }
}
Tip
Implementing `source()` enables tools like `anyhow` and error-printing utilities to walk the error chain and display each cause in sequence, giving users a full picture of what went wrong.
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.

RUST
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)
    }
}

RUST
// 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.

TOML
# Cargo.toml
[dependencies]
thiserror = "1"

RUST
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]

RUST
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).

TOML
# Cargo.toml
[dependencies]
anyhow = "1"

RUST
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".

RUST
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)
    }
}
Note
Use the alternate format when printing anyhow::Error to display the full error chain with each cause on a new line.
When to Use thiserror vs anyhow vs Manual

Approach

Best for

Callers can match variants?

Manual impl Error

Full control, no dependencies

Yes

thiserror

Library crates — typed, ergonomic error enums

Yes

anyhow

Application / binary code — zero boilerplate, rich context

No (use downcasting)

Box<dyn Error>

Quick prototypes, main functions

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 Debug

  • Never expose raw internal errors (SQL, OS codes) directly to users — wrap them in a user-facing message

RUST
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.

RUST
//! 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>;

RUST
//! 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 })
}
Tip
Mark public error enums with `#[non_exhaustive]`. This lets you add new variants in future library versions without breaking downstream code that matches on the enum — callers must always have a `_` catch-all arm.
Summary
Success
Custom error types are one of the places where Rust's type system pays off most clearly. Start with `thiserror` for library crates and `anyhow` for application code. When you need total control or have zero dependencies as a constraint, implement `Display`, `Debug`, `Error`, and `From` manually — it is straightforward once you have done it once. Always implement `source()` for wrapped errors so tools can display the full error chain.

Task

How

Define a custom error enum

#[derive(Debug)] + thiserror::Error or manual impls

Human-readable message

impl Display or #[error("...")]

Expose the inner cause

fn source() or #[source] / #[from]

Enable ? conversion

impl From<InnerError> or #[from]

Add context in app code

anyhow + .context() / .with_context()

Prevent breaking changes

#[non_exhaustive] on the error enum