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
andDisplay
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 anOption
to aResult
, returningOk
if theOption
contains a value, or anErr
if it containsNone
. In this example, if the vector is empty,vec.first()
returnsNone
, andok_or(DoubleError)
turns it into anErr(DoubleError)
. -
map_err()
: This method transforms the error type in aResult
. Here, if parsing fails,map_err(|_| DoubleError)
converts the parsing error (of typeParseIntError
) into our customDoubleError
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’sInto
trait to convertEmptyVec
into aBox<dyn Error>
. This conversion works becauseBox
implementsFrom
for any type that implements theError
trait. Using.into()
in this context transformsEmptyVec
from its original type into a boxed trait object (Box<dyn Error>
) that can be returned by the function, matching itsResult
type. -
map_err(|e| e.into())
: In theand_then
closure,map_err
is used to convert any parsing error into a boxed error. Here,map_err(|e| e.into())
takes theParseIntError
(or any other error type that implementsError
) and converts it toBox<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 aParseIntError
, 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: DefiningDoubleError
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: Thefmt
method inDisplay
provides custom error messages for eachDoubleError
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 implementingError
forDoubleError
, we make it compatible with Rust’s error-handling traits. Thesource()
method allows accessing underlying errors, if any:- For
EmptyVec
,source()
returnsNone
because there is no underlying error. - For
Parse
,source()
returns a reference to theParseIntError
, preserving the original error details.
- For
-
Using
From
for Automatic Conversion: TheFrom
trait allows automatic conversion of aParseIntError
into aDoubleError
. When aParseIntError
occurs (for example, when parsing fails), it can be converted into theDoubleError::Parse
variant. This makes?
usable forParseIntError
results, as they are converted toDoubleError
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 anErr
withDoubleError::EmptyVec
, providing a custom error if no element is found.first.parse::<i32>()?
: Tries to parse the first string element as ani32
. If parsing fails, theParseIntError
is automatically converted intoDoubleError::Parse
through theFrom
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.