15.6 Handling Multiple Error Types

15.6.1 Results and Options Embedded in Each Other

Sometimes, functions may return Option<Result<T, E>> when there are two possible issues: an operation might be optional (returning None), or it might fail (returning Err). The most basic way of handling mixed error types is to embed them in each other.

In the following code example, we have two possible issues: the vector can be empty, or the first element can contain invalid data:

use std::num::ParseIntError;
fn double_first(vec: Vec<&str>) -> Option<Result<i32, ParseIntError>> {
    vec.first().map(|first| {
        first.parse::<i32>().map(|n| 2 * n)
    })
}
fn main() {
    println!("{:?}", double_first(vec!["42"]));
    println!("{:?}", double_first(vec!["x"]));
    println!("{:?}", double_first(Vec::new()));
}

In the above example, first() can return None, and parse() can return a ParseIntError.

There are times when we'll want to stop processing on errors (like with ?) but keep going when the Option is None. The transpose function comes in handy to swap the Result and Option.

use std::num::ParseIntError;
fn double_first(vec: Vec<&str>) -> Result<Option<i32>, ParseIntError> {
    let opt = vec.first().map(|first| {
        first.parse::<i32>().map(|n| 2 * n)
    });
    opt.transpose()
}
fn main() {
    println!("The first doubled is {:?}", double_first(vec!["42"]));
    println!("The first doubled is {:?}", double_first(vec!["x"]));
    println!("The first doubled is {:?}", double_first(Vec::new()));
}

15.6.2 Defining a Custom Error Type

Sometimes, handling multiple types of errors as a single, custom error type can make code simpler and more consistent. Rust lets us define custom error types that streamline error management and make errors easier to interpret.

A well-designed custom error type should:

  • Implement the Debug and Display traits for easy debugging and user-friendly error messages.
  • Provide clear, meaningful error messages.
  • Optionally implement the std::error::Error trait, making it compatible with Rust’s error-handling ecosystem and enabling it to be used with other error utilities.

Example:

use std::fmt;
type Result<T> = std::result::Result<T, DoubleError>;
#[derive(Debug, Clone)]
struct DoubleError;
impl fmt::Display for DoubleError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "Invalid first item to double")
    }
}
fn double_first(vec: Vec<&str>) -> Result<i32> {
    vec.first()
        .ok_or(DoubleError) // Converts an Option to a Result, using DoubleError if None
        .and_then(|s| {
            s.parse::<i32>()
                .map_err(|_| DoubleError) // Converts any parsing error to DoubleError
                .map(|i| 2 * i) // Doubles the parsed integer if parsing is successful
        })
}
fn main() {
    println!("The first doubled is {:?}", double_first(vec!["42"]));
    println!("The first doubled is {:?}", double_first(vec!["x"]));
    println!("The first doubled is {:?}", double_first(Vec::new()));
}

The code example above defines a simple custom error type called DoubleError and uses the generic type alias type Result<T> = std::result::Result<T, DoubleError>; to save typing.

Explanation of Key Methods

  • ok_or(): This method is used to convert an Option to a Result, returning Ok if the Option contains a value, or an Err if it contains None. In this example, if the vector is empty, vec.first() returns None, and ok_or(DoubleError) turns it into an Err(DoubleError).

  • map_err(): This method transforms the error type in a Result. Here, if parsing fails, map_err(|_| DoubleError) converts the parsing error (of type ParseIntError) into our custom DoubleError type, allowing us to return a consistent error type across the function.

This design helps centralize error handling and makes the code more readable by transforming any encountered errors into our custom DoubleError, which carries a descriptive message. Using ok_or() and map_err() in this way keeps the code concise and improves its error-handling capabilities.

15.6.3 Boxing Errors

Using boxed errors can simplify code while preserving information about the original errors. This approach enables us to handle different error types in a unified way, though with the trade-off that the exact error type is known only at runtime, rather than being statically determined.

Rust’s standard library makes boxing errors convenient: Box can store any type implementing the Error trait as a Box<dyn Error> trait object. Through the From trait, Box can automatically convert compatible error types into this trait object.

use std::error;
use std::fmt;
type Result<T> = std::result::Result<T, Box<dyn error::Error>>;
#[derive(Debug, Clone)]
struct EmptyVec;

impl fmt::Display for EmptyVec {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "Invalid first item to double")
    }
}
impl error::Error for EmptyVec {}
fn double_first(vec: Vec<&str>) -> Result<i32> {
    vec.first()
        .ok_or_else(|| EmptyVec.into()) // Converts EmptyVec into a Box<dyn Error>
        .and_then(|s| {
            s.parse::<i32>()
                .map_err(|e| e.into()) // Converts the parsing error into a Box<dyn Error>
                .map(|i| 2 * i)
        })
}
fn main() {
    println!("The first doubled is {:?}", double_first(vec!["42"]));
    println!("The first doubled is {:?}", double_first(vec!["x"]));
    println!("The first doubled is {:?}", double_first(Vec::new()));
}

Explanation of Key Components

  • EmptyVec.into(): The .into() method here leverages Rust’s Into trait to convert EmptyVec into a Box<dyn Error>. This conversion works because Box implements From for any type that implements the Error trait. Using .into() in this context transforms EmptyVec from its original type into a boxed trait object (Box<dyn Error>) that can be returned by the function, matching its Result type.

  • map_err(|e| e.into()): In the and_then closure, map_err is used to convert any parsing error into a boxed error. Here, map_err(|e| e.into()) takes the ParseIntError (or any other error type that implements Error) and converts it to Box<dyn Error>. This way, we can return a consistent error type (Box<dyn Error>) regardless of the original error, while still preserving information about the specific error kind.

Why Use Boxed Errors?

Boxing errors in this way allows the Result type to accommodate any error that implements Error, making the code more flexible and simplifying error handling. This approach is especially useful in cases where multiple error types may arise, as it allows them all to be handled under a single type (Box<dyn Error>) without complex matching or conversion logic for each specific error type. The main drawback is that type information is only available at runtime, not compile-time, so specific error handling becomes less granular.

Boxed types will be discussed in more detail in a later chapter of the book.

15.6.4 Other Uses of ?

In the previous example, we used map_err to convert the error from a library-specific error type into a boxed error type:

.and_then(|s| s.parse::<i32>())
    .map_err(|e| e.into())

This kind of error conversion is common in Rust, so it would be convenient to simplify it. However, because and_then is not flexible enough for implicit error conversion, map_err becomes necessary in this context. Fortunately, the ? operator offers a more concise alternative.

The ? operator was introduced as a shorthand for either unwrapping a Result or returning an error if one is encountered. Technically, though, ? doesn’t just return Err(err)—it actually returns Err(From::from(err)). This means that if the error can be converted into the function’s return type via the From trait, ? will handle the conversion automatically.

In the revised example below, we use ? in place of map_err, as From::from converts any error from parse (a ParseIntError) into our boxed error type, Box<dyn error::Error>, as specified by the function’s return type.

use std::error;
use std::fmt;
use std::num::ParseIntError;
type Result<T> = std::result::Result<T, Box<dyn error::Error>>;
#[derive(Debug)]
struct EmptyVec;
impl fmt::Display for EmptyVec {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "Invalid first item to double")
    }
}
impl error::Error for EmptyVec {}

fn double_first(vec: Vec<&str>) -> Result<i32> {
    let first = vec.first().ok_or(EmptyVec)?;
    let parsed = first.parse::<i32>()?;
    Ok(2 * parsed)
}
fn main() {
    println!("The first doubled is {:?}", double_first(vec!["42"]));
    println!("The first doubled is {:?}", double_first(vec!["x"]));
    println!("The first doubled is {:?}", double_first(Vec::new()));
}

Why ? Works Here

This version of the code is simpler and cleaner than before. By using ? instead of map_err, we avoid extra conversion boilerplate. The ? operator performs the necessary conversions automatically because From::from is implemented for our error type, allowing it to convert errors from parse into our boxed error type.

Comparison with unwrap

This pattern is similar to using unwrap but is safer, as it propagates errors through Result types rather than panicking. These Result types must be handled at the top level of the function, ensuring that error handling is more robust and explicit.


15.6.5 Wrapping Errors

An alternative to boxing errors is to wrap different error types in a custom error type. This approach allows you to maintain distinct error cases while still unifying them under a single Result type.

In this example, we define DoubleError as an enum with specific variants for different error cases:

  • DoubleError::EmptyVec: Represents an error when the input vector is empty.
  • DoubleError::Parse(ParseIntError): Wraps a ParseIntError, representing a parsing failure, allowing the original parsing error to be retained and accessed.
use std::error;
use std::fmt;
use std::num::ParseIntError;
type Result<T> = std::result::Result<T, DoubleError>;
#[derive(Debug)]
enum DoubleError {
    EmptyVec,
    Parse(ParseIntError),
}
impl fmt::Display for DoubleError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match *self {
            DoubleError::EmptyVec =>
                write!(f, "Please use a vector with at least one element"),
            DoubleError::Parse(..) =>
                write!(f, "The provided string could not be parsed as an integer"),
        }
    }
}
impl error::Error for DoubleError {
    fn source(&self) -> Option<&(dyn error::Error + 'static)> {
        match *self {
            DoubleError::EmptyVec => None,
            DoubleError::Parse(ref e) => Some(e),
        }
    }
}
impl From<ParseIntError> for DoubleError {
    fn from(err: ParseIntError) -> DoubleError {
        DoubleError::Parse(err)
    }
}
fn double_first(vec: Vec<&str>) -> Result<i32> {
    let first = vec.first().ok_or(DoubleError::EmptyVec)?;
    let parsed = first.parse::<i32>()?;
    Ok(2 * parsed)
}
fn main() {
    println!("The first doubled is {:?}", double_first(vec!["42"]));
    println!("The first doubled is {:?}", double_first(vec!["x"]));
    println!("The first doubled is {:?}", double_first(Vec::new()));
}

Explanation of Key Components

  • The DoubleError Enum: Defining DoubleError as an enum allows each variant to represent a specific kind of error. This structure preserves the original error type, which can be helpful for debugging and enables us to provide targeted error messages.

  • Implementing Display for Custom Messages: The fmt method in Display provides custom error messages for each DoubleError variant. When the error is printed, users see clear, descriptive text based on the error type:

    • EmptyVec shows "Please use a vector with at least one element".
    • Parse(..) shows "The provided string could not be parsed as an integer".
  • Implementing Error for Compatibility: By implementing Error for DoubleError, we make it compatible with Rust’s error-handling traits. The source() method allows accessing underlying errors, if any:

    • For EmptyVec, source() returns None because there is no underlying error.
    • For Parse, source() returns a reference to the ParseIntError, preserving the original error details.
  • Using From for Automatic Conversion: The From trait allows automatic conversion of a ParseIntError into a DoubleError. When a ParseIntError occurs (for example, when parsing fails), it can be converted into the DoubleError::Parse variant. This makes ? usable for ParseIntError results, as they are converted to DoubleError automatically.

  • The double_first Function:

    • vec.first().ok_or(DoubleError::EmptyVec)?: Attempts to retrieve the first element of the vector. If the vector is empty, ok_or(DoubleError::EmptyVec) returns an Err with DoubleError::EmptyVec, providing a custom error if no element is found.
    • first.parse::<i32>()?: Tries to parse the first string element as an i32. If parsing fails, the ParseIntError is automatically converted into DoubleError::Parse through the From implementation, propagating the error.

Advantages and Trade-offs

This approach provides more specific error information and can be beneficial in cases where different error types require distinct handling or messaging. However, it does introduce additional boilerplate code, particularly when defining custom error types and implementing the Error trait. There are libraries, such as thiserror and anyhow, that can help reduce this boilerplate by providing macros for deriving or wrapping errors.