HomeArticles

Error handling in Rust

Stefan Baumgartner

Stefan on Mastodon

More on Rust

I started doing university lectures on Rust, as well as holding workshops and trainings. One of the parts that evolved from a couple of slides into a full-blown session was everything around error handling in Rust, since it’s so incredibly good!

Not only does it help making impossible states impossible, but there’s also so much detail to it that handling errors – much like everything in Rust – becomes very ergonomic and easy to read and use.

Making impossible states impossible #

In Rust, there are no things like undefined or null, nor do you have exceptions like you know it from programming languages like Java or C#. Instead, you use built-in enums to model state:

  • Option<T> for bindings that might possibly have no value (e.g. Some(x) or None)
  • Result<T, E> for results from operations that might error (e.g. Ok(val) vs Err(error))

The difference between the two is very nuanced and depends a lot on the semantics of your code. The way both enums work is very similar though. The most important thing, in my opinion, is that both types request from you to deal with them. Either by explicitly handling all states, or by explicitly ignoring them.

In this article, I want to focus on Result<T, E> as this one actually contains errors.

Result<T, E> is an enum with two variants:

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

T, E are generics. T can be any value, E can be any error. The two variants Ok and Err are globally available.

Use Result<T, E> when you have things that might go wrong. An operation that is expected to succeed, but there might be cases where it doesn’t. Once you have a Result value, you can do the following:

  • Deal with the states!
  • Ignore it
  • Panic!
  • Use fallbacks
  • Propagate errors

Let’s see what I mean in detail.

Deal with the error state #

Let’s write a little piece where we want to read a string from a file. It requires us to

  1. Read a file
  2. Read a string from this file

Both operations might cause a std::io::Error because something unforeseen can happen (the file doesn’t exist, or it can’t be read from, etc.). So the function we’re writing can return either a String or an io::Error.

use std::io;
use std::fs::File;

fn read_username_from_file(path: &str) -> Result<String, io::Error> {
let f = File::open(path);

/* 1 */
let mut f = match f {
Ok(file) => file,
Err(e) => return Err(e),
};

let mut s = String::new();

/* 2 */
match f.read_to_string(&mut s) {
Ok(_) => Ok(s),
Err(err) => Err(err),
}
}

This is what happens:

  1. When we open a file from path, it either can return a filehandle to work with Ok(file), or it causes an error Err(e). With match f we’re forced to deal with the two possible states. Either we assign the filehandle to f (notice the shadowing of f), or we return from the function by returning the error. The return statement here is important as we want to exit the function.
  2. We then want to read the contents into s, the string we just created. It again can either succeed or throw an error. The function f.read_to_string returns the length of bytes read, so we can safely ignore the value and return an Ok(s) with the string read. In the other case, we just return the same error. Note that I didn’t write a semi-colon at the end of the match expression. Since it’s an expression, this is what we return from the function at this point.

This might look very verbose (it is…), but you see two very important aspects of error handling:

  1. In both cases you’re expected to deal with the two possible states. You can’t continue if don’t do something
  2. Features like shadowing (binding a value to an existing name) and expressions make even verbose code easy to read and use

The operation we just did is often called unwrapping. Because you unwrap the value that is wrapped inside the enum.

Speaking of unwrapping

Ignore the errors #

If you’re very confident that your program won’t fail, you can simply .unwrap() your values using the built-in functions:

fn read_username_from_file(path: &str) -> Result<String, io::Error> {
let mut f = File::open(path).unwrap(); /* 1 */
let mut s = String::new();
f.read_to_string(&mut s).unwrap(); /* 1 */
Ok(s) /* 2 */
}

Here’s what happens:

  1. In all cases that might cause an error, we’re calling unwrap() to get to the value
  2. We wrap the result in an Ok variant which we return. We could just return s and drop the Result<T, E> in our function signature. We keep it because we use it in the other examples again.

The unwrap() function itself is very much like what we did in the first step where we dealt with all states:

// result.rs

impl<T, E: fmt::Debug> Result<T, E> {
// ...

pub fn unwrap(&self) -> T {
match self {
Ok(t) => t,
Err(e) => unwrap_failed("called `Result::unwrap()` on an `Err` value", &e),
}
}

// ...
}

unwrap_failed is a shortcut to the panic! macro. This means if you use .unwrap() and you don’t have a successful result, your software crashes. 😱

You might ask yourself: How is this different from errors that just crash the software in other programming languages? The answer is easy: You have to be explicit about it. Rust requires you to do something, even if it’s explicitly allowing to panic.

There are lots of different .unwrap_ functions you can use for various situations. We look at one or two of them further on.

Panic! #

Speaking of panics, you can also panic with your own panic message:

fn read_username_from_file(path: &str) -> Result<String, io::Error> {
let mut f = File::open(path).expect("Error opening file");
let mut s = String::new();
f.read_to_string(&mut s).expect("Error reading file to string");
Ok(s)
}

What .expect(...) does is very similar to unwrap()

impl<T, E: fmt::Debug> Result<T, E> {
// ...
pub fn expect(self, msg: &str) -> T {
match self {
Ok(t) => t,
Err(e) => unwrap_failed(msg, &e),
}
}
}

But, you have your panic messages in your hand, which you might like!

But even if we are explicit at all times, we may want our software not to panic and crash whenever we encounter an error state. We might want to do something useful, like providing fallbacks or … well … actually handling errors.

Fallback values #

Rust has the possibility to use default values on their Result (and Option) enums.

fn read_username_from_file(path: &str) -> Result<String, io::Error> {
let s = fs::read_to_string(path).unwrap_or("admin".to_string());
Ok(s)
}
  1. "admin" might not be the best fallback for a username, but you get the idea. Instead of crashing, we return a default value in the case of an error result. The method .unwrap_or_else takes a closure for more complex default values. fs::read_to_string is a shortcut from std::fs for exactly what we did above.

That’s better! Still, what we’ve learned so far is a trade-off between being very verbose, or allowing for explicit crashes, or maybe having fallback values. But can we have both? Concise code and error safety? We can!

Propagate the error #

One of the features I love most with Rust’s Result types is the possibility to propagate an error. Both functions that might cause an error have the same error type: io::Error. We can use the question mark operator after each operation to write code for the happy path (only success results), and return error results if something goes wrong:

fn read_username_from_file(path: &str) -> Result<String, io::Error> {
let mut f = File::open(path)?;
let mut s = String::new();
f.read_to_string(&mut s)?;
Ok(s)
}

In this piece, f is a file handler, f.read_to_string saves to s. If anything goes wrong, we return from the function with Err(io::Error). Concise code, but we deal with the error one level above:

fn main() {
match read_username_from_file("user.txt") {
Ok(username) => println!("Welcome {}", username),
Err(err) => eprintln!("Whoopsie! {}", err)
};
}

The great thing about it?

  1. We are still explicit, we have to do something! You can still find all the spots where errors can happen!
  2. We can write concise code as if errors wouldn’t exist. Errors still have to be dealt with! Either from us or from the users of our function.

The question mark operator also works on Option<T>, this also allows for some really nice and elegant code!

Propagating different errors #

The problem is though, that methods like this only work when the error types are the same. If we have two different types of errors, we have to get creative. Look at this slightly modified function, where we open and read files, but then parse the read content into a u64

fn read_number_from_file(filename: &str) -> Result<u64, ???> {
let mut file = File::open(filename)?; /* 1 */

let mut buffer = String::new();
file.read_to_string(&mut buffer)?; /* 1 */

let parsed: u64 = buffer.trim().parse()?; /* 2 */

Ok(parsed)
}
  1. These two spots can cause io::Error, as we know from the previous examples
  2. This operation however can cause a ParseIntError

The problem is, we don’t know which error we get at compile time. This is entirely up to our code running. We could handle each error through match expressions and return our own error type. Which is valid, but makes our code verbose again. Or we prepare for “things that happen at runtime”!

Check out our slightly changed function

use std::error;

fn read_number_from_file(filename: &str) -> Result<u64, Box<dyn error::Error>> {
let mut file = File::open(filename)?; /* 1 */

let mut buffer = String::new();
file.read_to_string(&mut buffer)?; /* 1 */

let parsed: u64 = buffer.trim().parse()?; /* 2 */

Ok(parsed)
}

This is what happens:

  • Instead of returning an error implementation, we tell Rust that something that implements the Error error trait is coming along.
  • Since we don’t know what this can be at compile-time, we have to make it a trait object: dyn std::error::Error.
  • And since we don’t know how big this will be, we wrap it in a Box. A smart pointer that points to data that will be eventually on the heap

A Box<dyn Trait> enables dynamic dispatch in Rust: The possibility to dynamically call a function that is not known at compile time. For that, Rust introduces a vtable that keeps pointers to the actual implementations. At runtime, we use these pointers to invoke the appropriate function implementations.

Memory layout of Box and Box

And now, our code is concise again, and our users have to deal with the eventual error.

The first question I get when I show this to folks in my courses is: But can we eventually check which type of error has happened? We can! The downcast_ref() method allows us to get back to the original type.

fn main() {
match read_number_from_file("number.txt") {
Ok(v) => println!("Your number is {}", v),
Err(err) => {
if let Some(io_err) = err.downcast_ref::<std::io::Error>() {
eprintln!("Error during IO! {}", io_err)
} else if let Some(pars_err) = err.downcast_ref::<ParseIntError>() {
eprintln!("Error during parsing {}", pars_err)
}
}
};
}

Groovy!

Custom errors #

It’s getting even better and more flexible if you want to create custom errors for your operations. To use custom errors, your error structs have to implement the std::error::Error trait. This can be a classic struct, a tuple struct or even a unit struct.

You don’t have to implement any functions of std::error::Error, but you need to implement both the Debug and the Display trait. The reasoning is that errors want to be printed somewhere. Here’s how an example looks like:

#[derive(Debug)] /* 1 */
pub struct ParseArgumentsError(String); /* 2 */

impl std::error::Error for ParseArgumentsError {} /* 3 */

/* 4 */
impl Display for ParseArgumentsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
  1. We derive the Debug trait.
  2. Our ParseArgumentsError is a tuple struct with one element: A custom message
  3. We implement std::error::Error for ParseArgumentsError. No need to implement anything else
  4. We implement Display, where we print out the single element of our tuple.

And that’s it!

Anyhow… #

Since a lot of the things you just learned a very common, there are of course crates available that abstract most of it. The fantastic anyhow crate is one of them and gives you trait object-based error handling with convenience macros and types.

Bottom line #

This is a very quick primer on error handling in Rust. There is of course more to it, but it should get you started! This is also my first technical Rust article, and I hope many more are coming. Let me know if you liked it and if you find any … haha … errors (ba-dum-ts 🥁), I’m just a tweet away.

More articles on Rust

Stay up to date!

3-4 updates per month, no tracking, spam-free, hand-crafted. Our newsletter gives you links, updates on fettblog.eu, conference talks, coding soundtracks, and much more.