Rusty Beginnings: Error Handling in Rust
Programming / April 26, 2024 • 9 min read
Tags: rust rusty-beginnings
I never liked the verbosity of Rust. Rust code always looked messy and so different from what I am used to. But since I started learning Rust, I have begun to enjoy the language. However, one thing that has been more difficult than what I have imagined it to be, is error handling.
For example, in Go it is normal to return Error
and simply set it nil
if there is no error. In Rust however, error handling has far more expressive. As part of my rusty learning experience, I would like to document how to perform structured error handling which involves managing errors in a consistent and maintainable manner to create more robust code.
In Rust, the primary built-in mechanisms for structured error handling are Option
and Result
. Both Option and Result are enums used for error handling and signaling the absence or presence of a value. They serve similar but distinct purposes:
Option<T>
is used when there might be a value (Some(T)
) or there might not be a value (None
). It’s typically used in situations where the absence of a value is not considered an error.Result<T, E>
is used when an operation might succeed with a value (Ok(T)
) or fail with an error (Err(E)
). This is used in cases where you need to handle potential errors and success states separately.
The Option
type is simpler than Result
because it only deals with the presence or absence of a value, without involving error handling logic. This makes Option
suitable for cases where the only concern is whether a value exists.
Because the Result
contains an error (Err(E)
), it is possible to convey why an operation failed, making it ideal for handling multiple errors. Benefits Result
provides:
Explicit Error Handling:
Result
requires explicit handling of the success or failure case, ensuring that errors cannot be ignored unintentionally. This contrasts with exceptions in other languages where an uncaught exception might propagate up the stack silently.Self-documenting Code: Function signatures with
Result
clearly communicate that an operation might fail, what type of value is returned on success, and what type of error might occur.Type Safety:
Result
is a generic type (Result<T, E>), allowing developers to specify the exact types of successful values and errors, which enhances type safety and clarity.
There different ways of handling these errors, here are several common strategies:
unwrap() or expect()
These methods will return the result or panic if there is an error or the value is None. This should generally not be used in production, because they can cause the program to panic, which is not a graceful way to handle errors in production. Rust’s philosophy encourages handling errors explicitly to avoid runtime panics.
1// Example using unwrap and expect
2let maybe_number: Option<i32> = Some(42);
3let number = maybe_number.unwrap(); // This will succeed and return 42
4let maybe_nothing: Option<i32> = None;
5let nothing = maybe_nothing.expect("This will panic with a custom message");
unwrap_or() or unwrap_or_else()
Similar to the above methods, expect they allow the user setting default value if there is an error/None. unwrap_or_else() returns a closure which allows for more computation or fallback logic. Can be used in production. Use unwrap_or() or unwrap_or_else() when a reasonable default value can allow your program to continue in a meaningful way.
1// Example using unwrap_or and unwrap_or_else
2let maybe_number: Option<i32> = None;
3let number = maybe_number.unwrap_or(0); // This will return 0
4
5let result: Result<i32, &str> = Err("error");
6let value = result.unwrap_or_else(|e| {
7 eprintln!("Encountered an error: {}", e);
8 -1 // return a default value if there's an error
9});
match()
Pattern matching which provides a way to handle multiple cases. Generates more verbose code but allows handling errors gracefully. Using match for error handling in Rust is particularly useful when you need fine-grained control over the handling of different error cases. For example:
- Handling Different Error Types
- When You Need to Execute Different Logic for Each Error
- Combining Error Handling with Value Unpacking
- Propagating Errors with Modifications
1// Example using match for error handling
2let result: Result<i32, &str> = Err("No value");
3
4let outcome = match result {
5 Ok(value) => format!("Received value: {}", value),
6 Err(e) => {
7 eprintln!("Error: {}", e);
8 "Error encountered".to_string()
9 }
10};
if let
If you just want to check the single value, if let
can be used. For cases where you’re interested in one particular success or error case. if let
is particularly handy when you’re only interested in one variant of the Result (usually the Ok variant) and want to ignore the rest, providing a more concise alternative to match for simple cases.
1// Example using if let for simple case handling
2let some_option: Option<i32> = Some(10);
3if let Some(value) = some_option {
4 println!("Received a value: {}", value);
5} else {
6 println!("No value received");
7}
?
The ?
operator in Rust is a convenient shorthand for error handling, primarily used to simplify the propagation of errors up the call stack. When applied to a result of the Result type, it automatically handles the Err variant by returning it from the current function, while letting the code proceed normally if the result is Ok. This operator significantly reduces boilerplate when dealing with sequences of operations that might fail, making the code more concise and readable. Use the ?
operator when you want to propagate errors upwards, allowing the caller of your function to handle them. Can be used in production.
1// Example using ? operator for error propagation
2fn get_value() -> Result<i32, String> {
3 let step1: Result<i32, String> = Ok(10);
4 let step2: Result<i32, String> = Ok(20);
5 let result = step1? + step2?; // Propagates error if any of the Results is Err
6 Ok(result)
7}
map_err()
The map_err
method is used with Result types to transform the error (Err) part of a Result into a different type. This is particularly useful when you need to convert an error from one type to another to match the expected error type of a function’s return value. map_err
takes a closure that takes the original error as a parameter and returns a new error type.
1// Example using map_err to transform error types
2let result: Result<i32, &str> = Err("404");
3
4let updated_error = result.map_err(|e| {
5 format!("Error occurred: {}", e) // Transforms &str error to String error
6});
and_then()
Can be used for chaining another operation that returns a Result
. If the initial Result
is Ok, and_then
calls its closure with the Ok value and expects another Result in return. If the initial Result is Err, the error is propagated without calling the closure.
1// Example using and_then for chaining operations that may fail
2let result: Result<i32, &str> = Ok(10);
3let final_result = result.and_then(|num| {
4 if num > 5 {
5 Ok(num * 2)
6 } else {
7 Err("Number is too low")
8 }
9});
Custom Error Types
Custom error types in Rust are used to define specific kinds of errors that can occur in your application, making it easier to handle these errors in a structured way. By creating custom error types, you can encapsulate different error scenarios into a single type that implements the Error
trait. This approach improves code readability and error management, especially in larger applications where multiple kinds of errors need to be handled differently.
To define a custom error type, you typically use an enum to represent different error conditions and derive traits like Debug
and Display
for basic error handling. Here’s an example that demonstrates how to define and use a custom error type:
1use std::fmt;
2
3// Define a custom error type using an enum
4#[derive(Debug)]
5enum NetworkError {
6 NotFound,
7 PermissionDenied,
8 Timeout,
9 Unexpected(String), // Allows for an unexpected error message
10}
11
12// Implement fmt::Display for the custom error
13impl fmt::Display for NetworkError {
14 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
15 match *self {
16 NetworkError::NotFound => write!(f, "Resource not found"),
17 NetworkError::PermissionDenied => write!(f, "Permission denied"),
18 NetworkError::Timeout => write!(f, "Operation timed out"),
19 NetworkError::Unexpected(ref msg) => write!(f, "Unexpected error: {}", msg),
20 }
21 }
22}
23
24// Use the custom error type in a function that might fail
25fn fetch_data(url: &str) -> Result<String, NetworkError> {
26 if url == "" {
27 return Err(NetworkError::NotFound);
28 } else if url.contains("forbidden") {
29 return Err(NetworkError::PermissionDenied);
30 } else if url.contains("timeout") {
31 return Err(NetworkError::Timeout);
32 }
33 Ok("Data fetched successfully".to_string())
34}
35
36fn main() {
37 match fetch_data("http://example.com/forbidden") {
38 Ok(data) => println!("Data: {}", data),
39 Err(e) => println!("Error: {}", e),
40 }
41}
From and Into for Error Type Conversions
Rust’s From and Into traits are a generalised way to convert types between each other. In the context of error handling, implementing the From trait for your custom error types allows for automatic conversion of other errors into your type within the ?
operator or try blocks, simplifying error handling and propagation.
1struct MyError(String);
2
3impl From<ParseIntError> for MyError {
4 fn from(err: ParseIntError) -> MyError {
5 MyError(err.to_string())
6 }
7}
8
9fn parse_number_to_my_error(number_str: &str) -> Result<i32, MyError> {
10 number_str.parse::<i32>().map_err(MyError::from)
11}
12
13// Or simply using `?` with automatic conversion
14fn parse_with_question_mark(number_str: &str) -> Result<i32, MyError> {
15 let num: i32 = number_str.parse()?;
16 Ok(num)
17}
Box<dyn Error>
In Rust, Box<dyn Error>
is a common way to handle heterogeneous error types in a flexible and dynamic manner. It is used when a function might return multiple types of errors, or when the exact type of the error is not known at compile time. This is achieved by boxing errors into a type-erased trait object.
What is dyn Error
?
dyn Error
is a trait object that represents any type that implements the std::error::Error
trait. By using dyn Error
, you can handle different error types dynamically.
Why use Box<dyn Error>
?
- Flexibility: It allows a function to return different types of errors, making it easier to write generic error-handling code.
- Simplicity: It simplifies function signatures when multiple error types are possible, reducing the need for custom error enumerations or other complex error handling schemes.
- Compatibility: Many standard library functions and third-party crates use
Box<dyn Error>
when they need to return various kinds of errors.
Using Box<dyn Error>
is particularly useful in applications where error handling needs to be generic or when integrating with multiple libraries that may return different types of errors. It provides a way to “erase” the specific types of errors, focusing instead on the fact that they are errors, which can be handled generically. However, it understandably has some drawbacks, namely the fact that you lose the specific type information of the errors. This means that while you can handle any error generically, you cannot perform operations that depend on knowing the exact type of the error at compile time. If you want to inspect the error, you need to downcast which can complicate the code and potentially impact performace. Lastly, dynamic dispatch introduces a small runtime cost because the exact method to call on an error object isn’t known until runtime. This is generally a minor concern but can be relevant in performance-critical applications.
Closing Thoughts
Learning how to perform structured error handling in Rust has not been easy, however after delving into error handling in Rust, I believe I am starting to recognise when to return certain error types and how to handle them appropriately. Rust’s expressiveness is growing on me, although at times it can feel overly verbose.
In this article, I have covered various error handling techniques that I have encountered in my journey. There are probably more techniques and tricks that I am unaware of, hopefully this article can be a starting point for further exploration in structured error handling in Rust.
CONTENTS