What you do if you meet unexpected — Error Handling in Rust

Amay B
CoderHack.com
Published in
8 min readSep 28, 2023

--

Rust has a strong focus on safety and handling errors correctly. The Rust philosophy promotes “fail-fast” behavior using panic! when errors occur. We'll explore how Rust encourages you to handle errors elegantly through the type system.

There are two main types of errors in Rust:

  1. Recoverable errors — These errors can be handled and the program can continue executing. We represent recoverable errors using the Result enum.
  2. Unrecoverable errors — These errors indicate a fault in the program logic and it cannot continue executing. We represent these using the panic! macro.

The panic! Macro for Unrecoverable Errors

The panic! macro halts the execution of the program and prints a failure message. You should use panic! when a program reaches an unrecoverable error state.

For example, here is a function that panics if it receives an invalid argument:

fn multiply(x: i32, y: i32) -> i32 {
if y == 0 {
panic!("Can't multiply by 0!");
}
x * y
}

fn main() {
multiply(2, 3); // Returns 6
multiply(2, 0); // Panics!
}

When you compile and run this code, it will print:

thread 'main' panicked at 'Can't multiply by 0!', src/main.rs:4:5

The call to multiply(2, 0) results in a panic due to the invalid argument.

We’ll explore panic handlers and recovering from panics in a later section. For now, just note that you should use panic! only for unrecoverable errors in your program logic. Recoverable errors should be handled elegantly using Rust's error handling features which we'll explore next!

Recoverable Errors: Using the Result Type

The Result enum is used to represent recoverable errors in Rust. It has two variants:

  • Ok(T): Contains a value of type T
  • Err(E): Contains an error of type E

We can define a Result like this:

enum Result<T, E> {
Ok(T),
Err(E)
}

The ? operator can be used to propagate errors and early return from a function. For example:

fn divide(a: i32, b: i32) -> Result<i32, DivideByZeroError> {
if b == 0 {
Err(DivideByZeroError {})
} else {
Ok(a / b)
}
}

fn main() {
let result = divide(10, 2);
let value = result?; // Uses the ? operator, returns Ok value or propagates Err
println!("{}", value); // Prints 5
}

We can use match expressions to handle the different Ok and Err cases:

match divide(10, 0) {
Ok(v) => println!("Result is {}", v),
Err(e) => println!("Error is {:?}", e),
}

The unwrap() method can be used to extract the value from an Ok result, panicking if the result is Err. expect() does the same but allows you to pass a custom panic message:

let v = result.unwrap(); // Panics if Err
let v = result.expect("Invalid operation"); // Panics with "Invalid operation" if Err

The Option enum is similar to Result but is used to represent the potential absence of a value. It has two variants:

  • Some(T): Contains a value of type T
  • None: Represents the absence of a value

We’ll explore error handling with panic! and defining your own error types in the next sections.

Unrecoverable Errors: Panicking with panic!

For unrecoverable errors, Rust provides the panic! macro to abort execution. When a panic occurs, the program unwinds the stack and cleans up resource before exiting. We use panic! when the program reaches an invalid state and cannot continue.

For example, here is a function that receives a mandatory argument. If the argument is missing, it panics:

fn require_arg(arg: i32) {
if arg <= 0 {
panic!("Argument must be greater than zero!");
}
}

Calling this function without an argument results in:

thread 'main' panicked at 'Argument must be greater than zero!', src/main.rs:3:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

To catch panics, we can define a panic handler:

fn main() {
panic::set_hook(Box::new(|panic_info| {
// Handles the panic
}));
}

The panic handler is passed a PanicInfo struct containing details about the panic. A basic panic handler may simply print the panic message:

fn main() {
panic::set_hook(Box::new(|panic_info| {
println!("{}", panic_info.message().unwrap());
}));
}

Panicking is a last resort in Rust for truly unrecoverable errors in your program. For expected and recoverable failures, it is better to return a Result rather than panicking. However, panics serve an important purpose in surfacing severe, unexpected errors. By using panic handlers, you can gain full control over the panic behavior in your application.

IV. Defining Your Own Error Types

To represent custom errors in our programs, we can define our own error types. Let’s say we are writing a CLI tool for validating usernames, and we want to represent invalid username errors. We can define an InvalidUsername enum like this:

#[derive(Debug)]
enum InvalidUsername {
Empty,
TooShort,
TooLong,
ContainsInvalidCharacters,
}

This enum represents the different possible invalid username errors. To implement the std::error::Error trait for our error type, we do this:

impl std::error::Error for InvalidUsername {
fn description(&self) -> &str {
match self {
InvalidUsername::Empty => "username cannot be empty",
InvalidUsername::TooShort => "username is too short",
InvalidUsername::TooLong => "username is too long",
InvalidUsername::ContainsInvalidCharacters =>
"username contains invalid characters",
}
}
}

The description() method returns a string description of the error, which is helpful for logging and debugging purposes.

We can return this error type from a function using -> Result<T, InvalidUsername>:

fn validate_username(username: String) -> Result<(), InvalidUsername> {
if username.is_empty() {
Err(InvalidUsername::Empty)
} else if username.len() < 3 {
Err(InvalidUsername::TooShort)
} else if username.len() > 15 {
Err(InvalidUsername::TooLong)
} else if !username.chars().all(|c| c.is_alphanumeric() || c == '_') {
Err(InvalidUsername::ContainsInvalidCharacters)
} else {
Ok(())
}
}

Then we can call the function and handle the error like this:

match validate_username(username) {
Ok(()) => println!("Username is valid!"),
Err(err) => println!("Invalid username: {}", err),
}

This prints the description of the exact error that occurred. By defining our own error type, we gain more descriptive and customized errors in our programs.

Working with Multiple Error Types

When writing Rust programs, it’s common to have multiple types of errors that can occur. There are a few ways to represent these multiple error types:

Enum of Error Types

We can define an enum that represents all the possible errors in a function:

enum CliError {
Io(std::io::Error),
Parse(num::ParseIntError),
InvalidArgument
}

This enum has variants for I/O errors (using std::io::Error), parse errors (using num::ParseIntError), and invalid arguments. We can return this enum from a function to represent these possible errors:

fn do_something() -> Result<(), CliError> {
// ...
}

However, as the number of possible errors grows, this enum becomes quite large. It also exposes the internal error types, which we may not want to expose in our public API.

Tuples of Errors

Another option is to use tuples to represent multiple errors:

fn do_something() -> Result<(i32, String), (std::io::Error, num::ParseIntError)> {
// ...
}

This function returns a tuple of an i32 and a String in the Ok case, and a tuple of two error types in the Err case.

However, tuples don’t give us named error variants, so the meaning of the two errors in the Err case is ambiguous. They also don’t allow us to add more error types in the future without changing the function signature.

Overall, for robust error handling in Rust, it’s best to define custom error types with enough variants and information to handle your error cases, without exposing too many internal details. Tuples can be OK for simple cases, but dedicated error enums are preferable for more complex error logic.

Let me know if you would like me to modify or expand the section in any way. I aimed for an explanatory long-form writing style with relevant code examples, as you specified. Please feel free to adapt the content as needed for your particular article.

Converting Errors

In Rust, we can convert between different error types using the From and Into traits. The From trait allows us to create an error from another error, while the Into trait consumes an error and produces a new error.

For example, say we have two error types:

enum CliError {
Io(io::Error),
Parse(num::ParseIntError)
}

enum ServiceError {
// ...
}

We can implement From<CliError> for ServiceError to convert CliError to ServiceError:

impl From<CliError> for ServiceError {
fn from(err: CliError) -> ServiceError {
match err {
CliError::Io(io_err) => // ...
CliError::Parse(parse_err) => // ...
}
}
}

Then we can use the ? operator to implicitly call From::from and convert the error:

fn do_something() -> Result<(), ServiceError> {
let num = "10".parse::<i32>()?; // Uses From<ParseIntError>
Ok(())
}

The ? operator will call From::from under the hood to convert the ParseIntError to our ServiceError and propagate it.

We can also implement Into<CliError> for io::Error and num::ParseIntError to consume those errors into our own CliError enum:

impl Into<CliError> for io::Error {
fn into(self) -> CliError {
CliError::Io(self)
}
}

impl Into<CliError> for num::ParseIntError {
fn into(self) -> CliError {
CliError::Parse(self)
}
}

Then we can call .into() to perform the conversion:

let err = io::Error::from(io::ErrorKind::NotFound);
let cli_err = err.into(); // cli_err is CliError::Io(_)

Converting between errors in this way allows us to bubble up errors and represent them in a useful manner for the needs of our program. Using the ? operator with From conversions also helps make error handling feel seamless in Rust.

VII. Best Practices

When working with errors in Rust, it’s important to follow some best practices to ensure a good developer experience. Here are some recommendations:

Prefer panic! only for unrecoverable errors

As we’ve discussed, panic! should only be used for truly unrecoverable errors that prevent your program from continuing to run correctly. For expected or recoverable errors, return a Result instead. This allows the caller to handle the error case gracefully.

Keep error types generic by using traits

Rather than defining concrete error types, define error traits that can be implemented for multiple error types. For example:

trait ApiError {
fn message(&self) -> &str;
}

Then you can return a Box from a function to indicate an error occurred, without tying the function to a specific error type. The caller can then downcast the Box to the appropriate error type. This keeps your API flexible and abstract.

Avoid large error enums when possible

While error enums are useful, very large enums can become difficult to work with. It may be better to define separate error types for logically distinct error cases, and then convert between them using the From and Into traits as needed.

Ensure error messages are clear and helpful

Well-written, helpful error messages are crucial for a good user experience. Be sure to provide context and actionable next steps if possible. For example, don’t just return “ IO Error”, return “Unable to read config file ‘/etc/app.conf’. Please check that the file exists and is readable.”

Return concrete error types, not just Box

While trait objects like Box have their uses, it is best to return concrete error types from functions and only box trait objects internally. This allows the caller to handle specific errors cases, match on the error type, call methods, etc. Only return a boxed trait object if the set of possible errors is truly heterogeneous and unknowable.

This covers some of the key best practices around error handling in Rust. Following these guidelines will help ensure you have a good experience dealing with errors in your Rust programs! Let me know if you have any other questions.

I hope this article has been helpful to you! If you found it helpful please support me with 1) click some claps and 2) share the story to your network. Let me know if you have any questions on the content covered.

Feel free to contact us at coderhack.com(at)xiv.in

--

--