How to Handle Multiple Error Types in One Function

Handle multiple error types in Rust by defining a custom enum and implementing the Error trait to unify failure cases.

When one error type isn't enough

You write a function that spawns a background thread and then tries to lock a shared counter. The thread join returns a std::thread::Result. The mutex lock returns a std::sync::LockResult. You try to return Result<(), ???> from the function. Rust stops you immediately. The compiler sees two different error types and refuses to compile. You cannot return a union of types without a common container.

This happens constantly in Rust. A function reads a file and parses JSON. A function connects to a database and queries a table. A function validates input and checks permissions. Every operation has its own specific error type. Rust demands that a function returns exactly one error type. The solution is to build a custom error enum that acts as a container for all possible failures.

The universal adapter pattern

Think of your function signature as a power outlet. It accepts one specific plug shape. Your operations are devices with different plugs. One device has a USB-C plug. Another has a Lightning plug. You cannot jam both into the outlet at once. You need adapters.

A custom error enum is that adapter. It defines a standard shape that your function can return. Inside the enum, each variant holds a specific error type. When a file read fails, you wrap the std::io::Error in the Io variant. When the JSON parser fails, you wrap the serde_json::Error in the Json variant. The function returns the enum. The caller sees one type. The enum preserves the original error inside the variant.

This pattern gives you two benefits. You get a single return type that satisfies the compiler. You also keep the original error information intact. The caller can match on the enum to handle specific cases or display a unified message. You never lose the root cause.

Minimal example: defining the enum

Start with an enum that lists every error variant your function can produce. Each variant should hold the original error type. This is a community convention. Wrapping the error preserves the error chain. Discarding the error makes debugging impossible.

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

#[derive(Debug)]
enum AppError {
    // Wrap the original error to preserve context.
    // The caller can inspect the inner error if needed.
    Io(io::Error),
    Parse(ParseIntError),
}

// Display provides the user-facing message.
// This is what println!("{}", error) will show.
impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            // Format the message based on the variant.
            // You can add extra context here.
            AppError::Io(err) => write!(f, "IO operation failed: {}", err),
            AppError::Parse(err) => write!(f, "Parsing failed: {}", err),
        }
    }
}

// The Error trait is a marker.
// It tells the compiler this type is a proper error.
// It enables integration with the ? operator and ecosystem tools.
impl std::error::Error for AppError {}

fn read_and_parse(input: &str) -> Result<i32, AppError> {
    // Simulate reading from a file.
    // The ? operator works here because we implement From below.
    let content = std::fs::read_to_string("data.txt")?;

    // Parse the content.
    // The ? operator converts ParseIntError to AppError automatically.
    let value: i32 = content.trim().parse()?;

    Ok(value)
}

The code defines AppError with two variants. Each variant wraps the underlying error. The Display implementation formats a message. The Error trait implementation marks the type as an error. The function read_and_parse returns Result<i32, AppError>.

How the compiler connects the dots

The ? operator does more than just return errors. It performs type conversion. When you use ? on an expression that returns Result<T, E1>, but the function returns Result<T, E2>, the compiler looks for an implementation of From<E1> for E2. If it finds one, it converts the error automatically.

This is where the real power lies. You can implement From for each error type you want to support. Once you do, you can use ? everywhere without manual mapping. The compiler handles the wrapping.

// Implement From for automatic conversion.
// This allows the ? operator to work seamlessly.
impl From<io::Error> for AppError {
    fn from(err: io::Error) -> Self {
        // Wrap the error in the Io variant.
        AppError::Io(err)
    }
}

impl From<ParseIntError> for AppError {
    fn from(err: ParseIntError) -> Self {
        // Wrap the error in the Parse variant.
        AppError::Parse(err)
    }
}

With these From implementations, the ? operator in read_and_parse works without any extra code. The compiler inserts the conversion calls. This is the idiomatic way to handle errors in Rust. It reduces boilerplate and keeps the logic clean.

Convention aside: The community prefers From implementations over manual map_err calls. map_err works, but it scatters conversion logic throughout your code. From centralizes it. If you need to change how an error is wrapped, you update one place. If you use map_err, you hunt through every call site.

Realistic scenario: threads and locks

The original technical kernel involves threads and mutexes. This is a realistic case where multiple error types collide. A function spawns threads, joins them, and locks a mutex. Each step has a different error type.

use std::fmt;
use std::sync::Mutex;
use std::thread;

#[derive(Debug)]
enum AppError {
    // Thread join can fail if the thread panics.
    ThreadJoin,
    // Mutex lock can fail if the mutex is poisoned.
    Lock,
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            AppError::ThreadJoin => write!(f, "A worker thread panicked"),
            AppError::Lock => write!(f, "Failed to acquire lock"),
        }
    }
}

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

// Implement From for thread join errors.
// This converts the opaque join error into our AppError.
impl From<thread::JoinError> for AppError {
    fn from(_: thread::JoinError) -> Self {
        AppError::ThreadJoin
    }
}

// Implement From for mutex lock errors.
// This converts the lock error into our AppError.
impl From<std::sync::PoisonError<Mutex<i32>>> for AppError {
    fn from(_: std::sync::PoisonError<Mutex<i32>>) -> Self {
        AppError::Lock
    }
}

fn run_workers() -> Result<i32, AppError> {
    let counter = Mutex::new(0);
    let mut handles = vec![];

    // Spawn ten threads that increment the counter.
    // Each thread moves a clone of the mutex guard logic.
    for _ in 0..10 {
        let handle = thread::spawn(move || {
            // Lock the mutex to modify the counter.
            // unwrap is safe here because we control the threads.
            // In production, you would propagate the error.
            let mut num = counter.lock().unwrap();
            *num += 1;
        });
        handles.push(handle);
    }

    // Join all threads.
    // The ? operator uses From<JoinError> to convert errors.
    for handle in handles {
        handle.join()?;
    }

    // Lock the mutex to read the final result.
    // The ? operator uses From<PoisonError> to convert errors.
    let final_value = *counter.lock()?;

    Ok(final_value)
}

fn main() {
    match run_workers() {
        Ok(value) => println!("Result: {}", value),
        Err(e) => eprintln!("Error: {}", e),
    }
}

The function run_workers returns Result<i32, AppError>. It spawns threads and joins them. The handle.join()? call uses the From<JoinError> implementation to convert thread panics into AppError::ThreadJoin. The counter.lock()? call uses the From<PoisonError> implementation to convert lock failures into AppError::Lock.

The code is clean. The error handling is centralized in the From implementations. The function body focuses on the logic. The compiler enforces that every error path is handled.

Convention aside: Keep unsafe blocks out of error handling. Error handling is about safety. Introducing unsafe to manipulate error types defeats the purpose. Stick to safe abstractions. If you need raw pointers, isolate them in a small helper with a // SAFETY: comment.

Pitfalls and compiler signals

Rust catches mistakes early. You will see specific error codes when you get the pattern wrong.

If you try to return two different error types without a common enum, the compiler rejects the code with E0308 (mismatched types). The compiler shows the expected type and the found type. You need to introduce the enum to unify them.

If you forget to implement std::error::Error, you can still return the enum. However, you lose integration with ecosystem tools. Crates like anyhow or thiserror expect the Error trait. Some logging frameworks use it. Implementing the trait is cheap. It takes one line. Always implement it.

If you use ? without a From implementation, the compiler rejects the code with E0277 (trait bound not satisfied). The error message tells you that From<E1> is not implemented for E2. You need to add the From impl or use map_err.

A common pitfall is discarding the original error. If you define AppError::Io without a field, you lose the std::io::Error. You cannot inspect the cause. You cannot chain errors. Always wrap the original error. Define AppError::Io(std::io::Error). This preserves the chain. Debugging becomes trivial. You can see the root cause.

Another pitfall is over-engineering. You do not need a custom error enum for every function. If a function only calls one operation, return that operation's error type. Only create an enum when you need to combine multiple error types. Premature abstraction adds complexity. Keep it simple.

Wrap the error. If you discard it, you're debugging blind.

Decision: choosing your error strategy

Rust offers several ways to handle errors. The right choice depends on your context.

Use a custom error enum when you are building a library and need to expose a stable, well-defined error API to your users. The enum documents all possible failures. Users can match on variants. You control the error types. This is the gold standard for libraries.

Use thiserror when you want a custom error enum but don't want to write boilerplate Display and Error implementations by hand. The crate generates the code for you. You define the enum with attributes. It reduces typing and prevents mistakes. This is the standard choice for most projects.

Use anyhow when you are writing an application binary and just need a quick way to propagate errors without defining types. anyhow::Error wraps any error. You can use ? everywhere. You don't need to define enums. This is perfect for main.rs and CLI tools.

Use Box<dyn std::error::Error> when you have a dynamic set of errors that cannot be known at compile time. This is rare. It erases type information. You lose the ability to match on specific errors. Avoid it unless you have a compelling reason.

Boilerplate is the price of clarity. Pay it, or automate it with thiserror.

Where to go next