More than you've ever wanted to know about errors in Rust

Cover image

To quote the Rust Book, 'errors are a fact of life in software'. This post goes over how to handle them.

Before talking about recoverable errors and the Result type, let's first touch on unrecoverable errors - a.k.a panics.

An Introduction to Unrecoverable Errors

Panics are exceptions a program can throw. It stops all execution in the current thread. When a panic is thrown it returns a short description of what went wrong as well as information about the position of the the panic.

fn main() {
    panic!("error!");
    println!("Never reached :(");
}

Running the above causes:

thread 'main' panicked at 'error!', examples\panics.rs:2:5

They are similar to throw in JavaScript and other languages, in that they don't require an annotation on the function to run and they can pass through function boundaries. However in Rust, panics cannot be recovered from, there is no way to incept a panic in the current thread.

fn send_message(s: String) {
    if s.is_empty() {
       panic!("Cannot send empty message");
    } else {
        // ...
    }
}

The send_message function is fallible (can go wrong). If this is called with an empty message then the program stops running. There is no way for the callee to track that an error has occurred.

For recoverable errors, Rust has a type for error handling in the standard library called a Result. It is a generic type, which means the result and error variant can basically be whatever you want.

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

Basic Error Creation and Handling

At the moment our send_message function doesn't return anything. This means no information can be received by the callee. We can change the definition to instead return a Result and rather than panicking we can early return a Result::Err.

fn send_message(s: String) -> Result<(), &'static str> {
    if s.is_empty() {
        // Note the standard prelude includes `Err` so the `Result::Err` and `Err` are equivalent
        return Result::Err("message is empty")
    } else {
        // ...
    }
    Ok(())
}

Now our function actually returns information about what went wrong we can handle it when we call it:

if let Err(send_error) = send_message(message) {
    show_user_error(send_error);
}

Dealing With Unused Results

In the above example we inspect the value of the item and branch on it. However, if we didn't inspect and handle the returned Result then the Rust compiler gives us a helpful warning about it so that you don't forget to explicitly deal with errors in your program.

|     send_message();
|     ^^^^^^^^^^^^^^^
= note: `#[warn(unused_must_use)]` on by default
= note: this `Result` may be an `Err` variant, which should be handled

The Result type can be found in most libraries. One of my favorite examples is the return type of the FromStr::from_str trait method. With str::parse (which uses the FromStr trait) we can do the following:

fn main() {
	let mut input = String::new();
	std::io::stdin().read_line(&mut input).unwrap();

	match input.trim_end().parse::<f64>() {
		Ok(number) => {
			dbg!(number);
		}
		Err(err) => {
			dbg!(err);
		}
	};
}

(We'll ignore the unwrap for now 😉)

$ cargo r --example input -q
10
[examples\input.rs:7] number = 10.0

$ cargo r --example input -q
100
[examples\input.rs:7] number = 100.0

$ cargo r --example input -q
bad
[examples\input.rs:10] err = ParseFloatError {
    kind: Invalid,
}

Here we can see when we type in a number we get a Ok variant with the number else we get a ParseFloatError

Files, Networks and Databases

All errors occur when you interact with the outside world or things outside the Rust runtime. One of the places where a lot of errors can occur is interacting with the file system. The File::open function attempts to open a file. This can fail for a variety of reasons. The filename is invalid, the file doesn't exist or you simply don't have permission to read the file. Notice the errors are well-defined and known before-hand. You can even access the error variants with the kind function and in order to implement your program logic or return an instructive error message to the user.

Aliasing Results and Errors

When you're working on a project you'll often find yourself repeating yourself when it comes to return types in function signatures:

fn foo() -> Result<SomeType, MyError> {
...
}

To give a concrete example, all functions which operate on the file system have the same errors (file not exists, invalid permissions). io::Result is a alias over a result but means that every function does not have to specify the error type:

pub type Result<T> = Result<T, io::Error>;

If you have an API which has a common error type, you may want to consider this pattern.

The Question Mark Operator

One of the best things about Results is the question mark operator, The question mark operator can short circuit Result error values. Let's look at a simple function which uploads text from a file. This can error in a bunch of different ways:

fn upload_file() -> Result<(), &'static str> {
    let text = match std::fs::read_to_string("file.txt").map_err(|_| "read file error") {
        Ok(value) => value,
        Err(err) => {
            return err;
        }
    };
    if let Err(err) = upload_text(text) {
        return err
    }
    Ok(())
}

Hang on, we're writing Rust not Go!

If a ? is postfixed on to a Result (or anything that implements try so also Option) we can obtain a functionally equivalent outcome with a much more readable and concise syntax.

fn upload_file() -> Result<(), &'static str> {
    let text = std::fs::read_to_string("file.txt").map_err(|_| "read file error")?;
    upload_text(text)?;
    Ok(())
}

As long as the calling function also returns a Result with the same Error type, ? saves a ton of explicit code being written. Moreover, the question-mark implicitly runs Into::into (which is automatically implemented for From implementors) on the error value. So we don't have to worry about converting the error before we use the operator:

// This derive an into implementation for `std::io::Error -> MyError`
#[derive(derive_enum_from_into::EnumFrom)]
enum MyError {
    IoError(std::io::Error)
    // ...
}

fn do_stuff() -> Result<(), MyError> {
    let file = File::open("data.csv")?;
    // ...
}

We will look at more patterns for combining error types later!

Introducing the Error Trait

The Error trait is defined in the standard library. It basically represents the expectations of error values - values of type E in Result<T,E>. The Error trait is implemented for many errors and provides a unified API for information on errors. The Error trait is a bit needy and requires that the error implements both Debug and Display. While it can be cumbersome to implement we will see some helper libraries for doing so later on.

In the standard library VarError (for reading environment variables) and ParseIntError (for parsing a string slice as a integer) are different errors. When we interact them we need to differentiate between the types because they have different properties and different stack sizes. To build a combination of them we could build a sum type using an enum. Alternatively we can use dynamically dispatched traits which handle varying stack sized items and other type information.

Using the above mentioned try syntax (?) we can convert the above errors to be dynamically dispatched. This makes it easy to handle different errors without building enums to combine errors.

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let key = std::env::var("NUMBER_IN_ENV")?;
    let number = key.parse::<i32>()?;
    println!("\"NUMBER_IN_ENV\" is {}", number);
    Ok(())
}

While this is an easy way to handle errors, it isn't easy to differentiate between the types and can make handling errors in libraries hard. More information on this later.

The Error trait vs Results and enums

One thing when using an enum is we can use match to branch on the enum error variants. On the other hand, with the dyn trait unless you go down the down casting path it is very hard to get specific information about the error:

match my_enum_error {
    FsError(err) => {
        report_fs_error(err)
    },
    DbError(DbError { err, database }) => {
        report_db_error(database, err)
    },
}

For reusable libraries it is better to use enums to combine errors so that users of your library can handle the specifics themselves. But for CLIs and other applications using the trait can be a lot simpler.

Methods on Result

Result and Option contains many useful functions. Here are some functions I commonly use:

Result::map()

Result::map maps or converts the Ok value if it exists. This can be more concise than using the ? operator.

fn string_to_plus_one(s: &str) -> Result<i32, std::num::ParseIntError> {
    s.parse::<i32>().map(|num| num + 1)
}

Result::ok()

Result::ok is useful for converting Results to Options

assert_eq!(Ok(2).ok(), Some(2));
assert_eq!(Err("err!").ok(), None);

Option::ok_or_else()

Option::ok_or_else is useful for going the other way in converting from Options to Results

fn get_first(vec: &Vec<i32>) -> Result<&i32, NotInVec> {
    vec.first().ok_or_else(|| NotInVec)
}

Error handling for iteration

Using results in iterator chains can be a little confusing. Luckily Result implements collect. We can use this to short circuit an iterator if an error occurs. In the following, if all the parses succeed then we get collected vec of numbers result. If one fails then it instead returns a Result with the failing Err.

fn main() {
	let a = ["1", "2", "not a number"]
		.into_iter()
		.map(|a| a.parse::<f64>())
		.collect::<Result<Vec<_>, _>>();
	dbg!(a);
}
[examples\iteration.rs:6] a = Err( ParseFloatError { kind: Invalid, }, )

Removing the "not a number" entry

[examples\iteration.rs:3] a = Ok( [ 1.0, 2.0, ], )

Because Rust iterators are piecewise and lazy the iterator can short circuit without evaluating parse on any of the later items.

More Panic

Special panics

todo!(), unimplemented!(), unreachable!() are all wrappers for panic! () which but are specialized to their situation. Panics have a special ! type, called the 'never type', which represents the result of computations that never complete (also means it can be passed anywhere):

fn func_i_havent_written_yet() -> u32 {
    todo!()
}

Sometimes there is Rust code which the compiler cannot properly infer is valid. For this type of situation, the unreachable! panic can be used:

fn get_from_vec_else_zero(a: Vec<i32>) -> i32 {
    if let Some(value) = a.get(2) {
        if let Some(prev_value) = a.get(1) {
            prev_value
        } else {
            unreachable!()
        }
    } else {
        0
    }
}

Unwrapping

unwrap is a method on Result and Option. They return the Ok or Some variant or else panic...

// result.unwrap()

let value = if let Ok(value) = result {
    value
} else {
    panic!("Unwrapped!")
};

The uses-cases for this are developer error and situations the compiler can't quite figure out. If you are just trying something and don't want to set up a full error handling system then they can be used to ignore compiler warnings.

Even if the situation calls for unwrap you are better off using expect which has an accompanying message - you'll be thanking your past self when the expect error message helps you find the root cause of an issue 2 weeks down the line.

Panics in the standard library

It is important to note that some of the APIs in the standard library can panic. You should look out for these annotations in the docs. One of them is Vec::remove. If you use this you should ensure that the argument is in its indexable range.

fn remove_at_idx(a: usize, vec: &mut Vec<i32>) -> Option<i32> {
    if a < idx.len() {
        Some(vec.remove(a))
    } else {
        None
    }
}

Handling Multiple Errors and Helper Crates

Handling errors from multiple libraries and APIs can become challenging as you have to deal with a bunch of different types. They are different sizes and contain different information. To unify the types we have to build a sum type using an enum, in order to ensure they have the same size at compile time.

enum Errors {
    FileSystemError(..),
    StringParseError(..),
    NetworkError(..),
}

Some crates for making creating these unifying enums easier:

thiserror

thiserror provides a derive implementation which adds the Error trait for us. As previously mentioned, to implement Error we have to implement display and thiserrors' #[error] attributes provide templating for the displayed errors.

use thiserror::Error;

#[derive(Error, Debug)]
pub enum DataStoreError {
    #[error("data store disconnected")]
    Disconnect(#[from] io::Error),
    #[error("the data for key `{0}` is not available")]
    Redaction(String),
    #[error("invalid header (expected {expected:?}, found {found:?})")]
    InvalidHeader {
        expected: String,
        found: String,
    },
    #[error("unknown data store error")]
    Unknown,
}

anyhow

anyhow provides an ergonomic and idiomatic alternative to explicitly handling errors. It is similar to the previously mentioned error trait but has additional features such as adding context to thrown errors.

This is really, really, useful when you want to convey errors to an application's users in a context-aware fashion:

use anyhow::{bail, Result, Context};

fn main() -> Result<()> {
    println!("Hello World!");
    func1().context("while calling func1")?;
    Ok(())
}

fn func1() -> Result<()> {
    func2().context("while calling func2")
}

fn func2() -> Result<()> {
    bail!("Hmm something went wrong ")
}
Error: while calling func1

Caused by:
    0: while calling func2
    1: Hmm something went wrong

Similar to the Error trait, anyhow suffers from the fact you can't match on anyhow's result error variant. This is why it is suggested in anyhow's docs to use anyhow for applications and thiserror for libraries.

eyre

Finally, eyre is a fork of anyhow and adds more backtrace information. It's highly customisable and using color-eyre we get colors in our panic messages - a little color always brightens up the dev experience.

The application panicked (crashed).
Message:  test
Location: examples\color_eyre.rs:6

  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ BACKTRACE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
                                ⋮ 13 frames hidden ⋮
  14: core::ops::function::FnOnce::call_once<enum$<core::result::Result<tuple$<>,eyre::Report>, 1, 18446744073709551615, Err> (*)(),tuple$<> ><unknown>
      at /rustc/7737e0b5c4103216d6fd8cf941b7ab9bdbaace7c\library\core\src\ops\function.rs:227
                                ⋮ 17 frames hidden ⋮

Finishing Up

Thank you for reading this article! Error handling can be tough, but with this guide hopefully you'll have a better idea of how to ensure you can reliably track errors and make debugging your Rust apps that much easier.

This blog post is powered by shuttle - The Rust-native, open source, cloud development platform. If you have any questions, or want to provide feedback, join our Discord server!
Share article
rocket

Build the Future of Backend Development with us

Join the movement and help revolutionize the world of backend development. Together, we can create the future!