How to handle multiple error types

Define a custom enum listing all error variants and implement the Error trait to handle them uniformly.

The multiple error type problem

You're writing a function that reads a configuration file and extracts a port number. The file might not exist. The number inside might be garbage text. You write the function signature as fn load_port() -> Result<u16, ???>. The compiler stares back. You have two different error types waiting to happen, and Rust refuses to guess which one you mean.

This is the multiple error types problem. It shows up the moment your code touches more than one external crate or system call. A function can only return one error type. That type is part of the function's contract. If a function can fail in two ways, you need a single type that represents both possibilities.

The solution: a custom enum

Rust functions return exactly one error type. That type is part of the function's contract. If a function can fail in two ways, you need a single type that represents both possibilities. The standard tool is a custom enum.

An enum acts like a tagged union. It holds one variant at a time. Each variant wraps a specific error type. You define the enum, list the variants, and implement the Error trait so the rest of the ecosystem treats it like any other error.

Think of it like a universal adapter. Your function has one output slot. The adapter has multiple input jacks. You plug the specific error into the correct jack, and the adapter presents a single unified shape to the caller.

Minimal example

Here is the bare minimum to make multiple errors work. You define the enum, implement Display for user-facing messages, and implement Error to mark the type as a proper error.

use std::fmt;
use std::io;
use std::num::ParseIntError;

/// Wraps IO and parsing errors into a single type.
#[derive(Debug)]
enum AppError {
    Io(io::Error),
    Parse(ParseIntError),
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            // Format the inner error with a prefix.
            AppError::Io(e) => write!(f, "IO error: {}", e),
            AppError::Parse(e) => write!(f, "Parse error: {}", e),
        }
    }
}

impl std::error::Error for AppError {}

fn read_and_parse() -> Result<u32, AppError> {
    // Simulate an IO error.
    let io_result: Result<String, io::Error> = Err(io::Error::new(io::ErrorKind::Other, "disk full"));
    // Convert io::Error to AppError::Io manually.
    let data = io_result.map_err(AppError::Io)?;
    // Simulate a parse error.
    let parse_result: Result<u32, ParseIntError> = "not_a_number".parse();
    // Convert ParseIntError to AppError::Parse manually.
    Ok(parse_result.map_err(AppError::Parse)?)
}

The enum AppError has two variants. Each variant holds a field of a specific type. This is called a tuple variant. When read_and_parse returns Err(AppError::Io(...)), the caller receives a single AppError value. The caller can match on it to see which variant it is.

The Display implementation controls how the error prints. The Error trait implementation is empty here, but it marks the type as a proper error. This allows you to use ? on Result<T, AppError> in functions that return Result<T, AppError>. Without the Error trait, some crates refuse to accept your type.

Add #[derive(Debug)] to the enum. The compiler generates a debug representation automatically. The Error trait requires Debug, so this is mandatory. If you skip it, the compiler rejects the code with E0277 (trait bound not satisfied) for the Debug trait.

The From trait and automatic conversion

The manual .map_err calls in the minimal example work, but they clutter the code. Every error propagation requires a conversion. The community convention is to implement the From trait for each inner error type.

From defines a conversion from one type to another. Once you implement impl From<X> for MyError, the ? operator handles the conversion automatically. You stop writing .map_err everywhere. The code reads like a straight line.

use std::fmt;
use std::io;
use std::num::ParseIntError;
use std::fs;

/// Errors for the config loader.
#[derive(Debug)]
enum ConfigError {
    Io(io::Error),
    Parse(ParseIntError),
}

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            ConfigError::Io(e) => write!(f, "IO error: {}", e),
            ConfigError::Parse(e) => write!(f, "Parse error: {}", e),
        }
    }
}

impl std::error::Error for ConfigError {}

// Allow automatic conversion from io::Error.
impl From<io::Error> for ConfigError {
    fn from(err: io::Error) -> Self {
        ConfigError::Io(err)
    }
}

// Allow automatic conversion from ParseIntError.
impl From<ParseIntError> for ConfigError {
    fn from(err: ParseIntError) -> Self {
        ConfigError::Parse(err)
    }
}

/// Loads the port from a file.
fn load_port(path: &str) -> Result<u16, ConfigError> {
    // Read the file. The ? operator uses From<io::Error> automatically.
    let content = fs::read_to_string(path)?;
    // Parse the number. The ? operator uses From<ParseIntError> automatically.
    let port = content.trim().parse()?;
    Ok(port)
}

The ? operator checks the result. If it's an Err, the operator calls From::from to convert the error type to the function's return error type. If the conversion succeeds, the function returns early with the converted error. If no From implementation exists, the compiler rejects the code with E0277 (the trait From<X> is not implemented for MyError).

Implement From for every variant. It saves you from writing map_err on every line. This pattern scales well. Add a new error type? Add a variant, implement From, and you're done.

Error chains and source

When you wrap an error, you preserve the original context. The Error trait has a source method that returns the underlying cause. Implementing source allows tools to print the full chain of errors. This turns a flat error message into a debuggable stack.

impl std::error::Error for ConfigError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            ConfigError::Io(e) => Some(e),
            ConfigError::Parse(e) => Some(e),
        }
    }
}

The source method returns Option<&(dyn Error + 'static)>. It points to the inner error. If the error has no cause, return None. Most standard library errors implement Error, so they can be returned as the source.

Convention aside: manual source implementation is verbose. The ecosystem standard for custom errors is the thiserror crate. It provides a derive macro that generates Display, Error, From, and source implementations from annotations. You write less code and get fewer bugs.

Always implement source. It turns a flat error message into a debuggable chain.

Pitfalls and compiler errors

Custom error enums introduce a few common traps. The compiler catches most of them, but the messages can be opaque if you don't know what to look for.

If you forget to implement std::error::Error, the compiler rejects code that expects an error trait. You'll see E0277 (trait bound not satisfied) when passing your error to crates like anyhow or eyre. These crates require the Error trait to work.

If you forget Display, you can't print the error. The compiler complains with E0277 regarding the Display trait. Many error handling crates require Display to format the error message.

If you use ? without From, the compiler says "the trait From<X> is not implemented for MyError". You must add map_err or implement From. The error message points to the line with ?.

If you mix error types in a match arm, the compiler rejects the code with E0308 (mismatched types). Each arm of a match must return the same type. Ensure all branches return MyError or use ? to propagate.

Trust the borrow checker. It usually has a point.

Decision: when to use this vs alternatives

Rust offers several ways to handle errors. Choose the right tool based on your context.

Use a custom enum when you are building a library and want to expose specific error variants to callers. Callers need to match on the error to decide how to recover. The enum gives them precise control.

Use anyhow when you are writing an application binary and just need to propagate errors to the top level. You don't care about the specific variant; you just want to print the chain and exit. anyhow handles conversions automatically and requires zero boilerplate.

Use thiserror when you have a custom enum but want to avoid boilerplate. The derive macro generates Display, Error, and From implementations for you. It is the standard for library errors in the modern ecosystem.

Use Box<dyn std::error::Error> when you need a generic error type in a trait object or callback, and performance isn't critical. This erases the concrete type at a small runtime cost. It is useful for dynamic dispatch scenarios.

Libraries expose enums. Applications use anyhow. Derive macros handle the rest.

Where to go next