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:
- Define an enum with variants for each potential error source, including custom application-specific errors.
- Implement
std::fmt::Debug
(usually via#[derive(Debug)]
) for debugging output. - Implement
std::fmt::Display
to provide user-friendly error messages. - Implement
std::error::Error
to integrate with Rust’s error handling ecosystem (e.g., for source chaining). - 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 genericimpl<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 generateDisplay
,Error
, andFrom
implementations for your custom error enums.anyhow
: Best suited for applications. Provides ananyhow::Error
type (similar toBox<dyn Error>
but with context/backtrace) andanyhow::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 ?
.