15.4 Handling Multiple Error Types

Functions often call multiple operations that can fail with different error types (e.g., io::Error from file operations, ParseIntError from string parsing). However, a function returning Result<T, E> can only specify a single error type E. How can we handle this?

15.4.1 Defining a Custom Error Enum

The most idiomatic and type-safe approach is to define a custom error enum that aggregates all possible error types the function might produce.

Steps:

  1. Define an enum with variants for each potential error source, including custom application-specific errors.
  2. Implement std::fmt::Debug (usually via #[derive(Debug)]) for debugging output.
  3. Implement std::fmt::Display to provide user-friendly error messages.
  4. Implement std::error::Error to integrate with Rust’s error handling ecosystem (e.g., for source chaining).
  5. Implement From<OriginalError> for each underlying error type. This allows the ? operator to automatically convert the original error into your custom error type.
use std::fmt;
use std::fs;
use std::io;
use std::num::ParseIntError;

// 1. Define custom error enum
#[derive(Debug)] // 2. Implement Debug
enum ConfigError {
    Io(io::Error),       // Wrapper for I/O errors
    Parse(ParseIntError),    // Wrapper for parsing errors
    MissingValue(String),    // Custom application error
}

// 3. Implement Display for user messages
impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            ConfigError::Io(e) => write!(f, "Configuration IO error: {}", e),
            ConfigError::Parse(e) => write!(f, "Configuration parse error: {}", e),
            ConfigError::MissingValue(key) =>
                write!(f, "Missing configuration value for '{}'", key),
        }
    }
}

// 4. Implement Error trait
impl std::error::Error for ConfigError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            // No 'ref' needed here due to match ergonomics on '&self'
            ConfigError::Io(e) => Some(e),   // 'e' is automatically '&io::Error'
            ConfigError::Parse(e) => Some(e), // 'e' is automatically '&ParseIntError'
            ConfigError::MissingValue(_) => None,
        }
    }
}

// 5. Implement From<T> for automatic conversion with '?'
impl From<io::Error> for ConfigError {
    fn from(err: io::Error) -> ConfigError {
        ConfigError::Io(err)
    }
}

impl From<ParseIntError> for ConfigError {
    fn from(err: ParseIntError) -> ConfigError {
        ConfigError::Parse(err)
    }
}

// Type alias for convenience
type Result<T> = std::result::Result<T, ConfigError>;

// Example function using the custom error and '?'
fn get_config_port(path: &str) -> Result<u16> {
    let content = fs::read_to_string(path)?; // '?' calls ConfigError::from(io::Error)

    let port_str = content
        .lines()
        .find(|line| line.starts_with("port="))
        .map(|line| line.trim_start_matches("port=").trim())
        .ok_or_else(|| ConfigError::MissingValue("port".to_string()))?; //Custom error

    let port = port_str.parse::<u16>()?; // '?' calls ConfigError::from(ParseIntError)
    Ok(port)
}

fn main() {
  // Setup dummy files
  fs::write("config_good.txt", "host=localhost\nport= 8080\n").unwrap();
  fs::write("config_bad_port.txt", "port=xyz").unwrap();
  fs::write("config_no_port.txt", "host=example.com").unwrap();

    println!("Good config: {:?}", get_config_port("config_good.txt"));
    println!("Bad port config: {:?}", get_config_port("config_bad_port.txt"));
    println!("No port config: {:?}", get_config_port("config_no_port.txt"));
    println!("Missing file: {:?}", get_config_port("config_missing.txt"));

  // Cleanup
  fs::remove_file("config_good.txt").ok();
  fs::remove_file("config_bad_port.txt").ok();
  fs::remove_file("config_no_port.txt").ok();
}

This approach provides the best type safety and clarity, allowing callers to match on specific error variants. The boilerplate for implementing traits can be reduced using libraries like thiserror.

15.4.2 Boxing Errors with Box<dyn Error>

For simpler applications or when detailed error matching by the caller is less critical, you can use a trait object to represent any error type that implements std::error::Error. This is typically done using Box<dyn std::error::Error + Send + Sync + 'static>. The Send and Sync bounds are often needed for thread safety, and 'static ensures the error type doesn’t contain non-static references.

A type alias simplifies this: type GenericResult<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync + 'static>>;

use std::error::Error;
use std::fs;
use std::num::ParseIntError;

// Type alias for a Result returning a boxed error trait object
type GenericResult<T> = std::result::Result<T, Box<dyn Error + Send + Sync + 'static>>;

fn get_config_port_boxed(path: &str) -> GenericResult<u16> {
    let content = fs::read_to_string(path)?; // io::Error automatically boxed by '?'

    let port_str = content
        .lines()
        .find(|line| line.starts_with("port="))
        .map(|line| line.trim_start_matches("port=").trim())
        // Need to create an Error type if 'port=' is missing
        .ok_or_else(|| Box::<dyn Error + Send + Sync +
         'static>::from("Missing 'port=' line in config"))?;

    // ParseIntError automatically boxed by '?'
    let port = port_str.parse::<u16>()?;
    Ok(port)
}

fn main() {
  // Setup dummy files
  fs::write("config_good_boxed.txt", "host=localhost\nport= 8080\n").unwrap();
  fs::write("config_bad_port_boxed.txt", "port=xyz").unwrap();
  fs::write("config_no_port_boxed.txt", "host=example.com").unwrap();

println!("Good config: {:?}", get_config_port_boxed("config_good_boxed.txt"));
println!("Bad port config: {:?}", get_config_port_boxed("config_bad_port_boxed.txt"));
println!("No port config: {:?}", get_config_port_boxed("config_no_port_boxed.txt"));
println!("Missing file: {:?}", get_config_port_boxed("config_missing.txt"));

  // Cleanup
  fs::remove_file("config_good_boxed.txt").ok();
  fs::remove_file("config_bad_port_boxed.txt").ok();
  fs::remove_file("config_no_port_boxed.txt").ok();
}

Advantages:

  • Less boilerplate than custom enums.
  • Flexible; can hold any error type implementing the Error trait.
  • The ? operator works seamlessly because the standard library provides a generic impl<E: Error + Send + Sync + 'static> From<E> for Box<dyn Error + Send + Sync + 'static>.

Disadvantages:

  • Type Information Loss: The caller only knows an error occurred, not its specific type, making pattern matching on the error type impossible without runtime type checking (downcasting), which is less idiomatic.
  • Runtime Cost: Incurs heap allocation (Box) and dynamic dispatch overhead.

This approach is common in application-level code or examples where simplicity is prioritized over granular error handling by callers. Libraries like anyhow build upon this pattern, adding features like context and backtraces.

15.4.3 Using Error Handling Libraries

The Rust ecosystem offers crates that significantly reduce the boilerplate associated with error handling:

  • thiserror: Ideal for libraries. Uses procedural macros (#[derive(Error)]) to automatically generate Display, Error, and From implementations for your custom error enums.
  • anyhow: Best suited for applications. Provides an anyhow::Error type (similar to Box<dyn Error> but with context/backtrace) and anyhow::Result<T> type alias. Simplifies returning errors from various sources without defining custom enums.

Exploring these crates is recommended once you are comfortable with the fundamental concepts of Result and ?.