Sep 9, 2024

Error management in Rust

Rust is a relatively new language that is gaining more and more popularity. Its strength is in having a strict compiler that tries to remove entirely some common development errors you would find in other languages, primarily C and its whole class of memory issues.

One of the interesting things about Rust is the error management. Things are done so that it is (normally) impossible to just ignore an error case and force you to deal with them all. Let’s take this very small C snippet:

#include <fcntl.h>
#include <unistd.h>
#include <string.h>

static char const * const HELLO = "Hello, world!\n";

int main(void) {
    int fd = open("foo.bar", O_WRONLY | O_CREAT, 0666);
    write(fd, HELLO, strlen(HELLO));
    close(fd);
    return 0;
}

An experimented C developer would be able to tell you that the open, write and close calls can fail, and that you should check them. In the current case, if the program cannot execute any of these calls, it just fails silently. More annoyingly, even when enabling all compiler warning flags, even the very broad -Weverything clang provides, there is absolutely no warning on this behavior.

You should, after each of these calls, check if they returned -1, and in that case, use errno and other functions to determine what went wrong, manually print a diagnostic message and exit the program, cleaning resources as needed. The error information – or at least, the information of the presence of an error, not the error itself is the same type and saved in the same place as a valid return, which makes it painfully easy to ignore the error and use that invalid value in next calls like we do here. Again, compiler does not even emit a warning on this, making it very easy to forget to check an error.

Also, amusingly and to add to the pile of issues, in my first writing I forgot the mode argument to open, which created a file with nonsensical permissions. Again, not a warning to be seen.

So, how does Rust handle that?

The Result type

Let’s check what the prototype of Rust’s equivalent to open is:

fn create<P: AsRef<Path>>(path: P) -> Result<File>

Rust actually provides, in std::fs::File, two functions, open and create, to easily represent opening in O_RDONLY and O_WRONLY | O_CREAT modes respectively, which are the most common. It also provides, for more specific cases, the options function giving you a builder pattern to set your options safely, but our concern here is the result type. For our example, the equivalent is create.

The return type of create is Result<File>. Actually, Rust takes some shortcuts in its standard library, and in this case, uses an alias to remove an often repeated argument. The actual type is std::io::Result<File> which expands to Result<File, std::io::Error>. It is an implementation detail, but we need the expanded type here to understand.

A result is an enum, which can takes a closed amount of values. Unlike C enums, which are just numerical values with labels, Rust enum variants can be simple tagged values as well as more complex structures. Take a closer look at the Result type definition:

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

The enum Result can take only two values: Ok, or Err. Each of these variants are a 1-item tuple, containing an item of type T or E from the generic typing. You cannot access the T value directly, however: you have in some way or another eliminate the possibility of the result being Err. The most straightforward way would be with match:

match File::open("foo.bar") {
    Ok(f) => { write!(f, "Hello, world!\n"); }
    Err(e) => { println!("Error opening file: {}", e); }
}

You cannot work on the file if an error happens, as you will not be able to get a File value from Err, needed to do write operations. You have to at least throw the error away, which is then an active choice.

If you put this code in a Rust file and compile it, you will actually get a warning:

warning: unused `Result` that must be used
 --> src/main.rs:7:13
  |
7 |             write!(f, "Hello, world!\n");
  |             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: this `Result` may be an `Err` variant, which should be handled
  = note: `#[warn(unused_must_use)]` on by default

The call to write! is also returning a Result, and by default, the compiler will warn you if you call something that returns a Result and you ignore it. This way, it is way harder to accidentally ignore an error case.

Now, you can add another match block around write! to check for error at this point, but doing so gets heavy quickly code-wise. Thankfully, Rust provides tools to ease all of that.

Managing errors

Result provides some useful functions to deal with errors without having to match all the time. The first and simplest one is unwrap. Calling it on a Result containing an Err will cause a panic!, crashing the program instantly, and otherwise just returning the T value. So, using it, we can reduce our code to this:

let mut f = File::open("foo.bar").unwrap();
write!(f, "Hello, world!\n").unwrap();

The code is still rather small, and unlike the C sample above, an error will actually close the program instantly with both an error code and a message printed explaining what happened:

thread 'main' panicked at src/main.rs:5:42:
called `Result::unwrap()` on an `Err` value: Os { code: 13, kind: PermissionDenied, message: "Permission denied" }

However, while very practical, unwrap is not really known as best practice. It can be useful for small tests or temporary handling, but you really should not rely on it for long-lasting code. Or put it for cases where you are absolutely sure that you cannot have an error.

First of all, it does not give a lot of information. You do have the source file and line, but it can still make it hard to get good information on what happened. Thankfully, unwrap has a little brother, expect, which allows to make the error a bit more informative by adjoining a custom message:

let mut f = File::create("foo.bar").expect("Cannot open file");
write!(f, "Hello, world!\n").expect("Cannot write to file");

If you run this code, you will see that the message in expect is prefixed to the lower error code. In big code bases, this additional context can save a lot of debugging time, and it is barely longer to write.

It is advised to always favor expect to unwrap so you have a bit more context in case of failure.

What if, however, you do not want to treat an error as an exit? Take for example reading an environment variable: if it does not exist, I want to continue my program anyway. Well, match still works:

let var = match std::env::var("MY_VAR") {
    Ok(v) => v,
    Err(_) => "foo".to_string(),
};

You can see we completely ignore the error in this case. However, there are shorter ways! Result provides unwrap_or and some other functions for these use cases.

let var = std::env::var("MY_VAR").unwrap_or("foo".to_string());

Note that in this case, the value given to unwrap_or is always calculated to be passed to the function, which is OK for trivial values, but not for expensive to calculate functions or stuff that has side effects. In that case, you have unwrap_or_else which takes a lambda, executed only if the Err case is encountered:

let var = std::env::var("MY_VAR").unwrap_or_else(|| "foo".to_string());

In this last example, the "foo" string copy is allocated only if the environment variable is not found, while on the previous one, it was always allocated, then wasted if you had a Ok.

Also, if your T type implements Default, you can just use unwrap_or_default. If an empty string is OK for you:

let var = std::env::var("MY_VAR").unwrap_or_default();

And that is only scratching the surface.

And some more that I did not describe for some more use cases you could have. You can check Result’s documentation to see all the available methods and a complete description for each.

Going back up

What if I told you that Rust makes it easy to make errors bubble up your stack, and making writing your program to exit gracefully from its main even in deep error cases much easier?

Let us take our example back, and put it in a separate function returning a Result:

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

fn say_hello() -> Result<(), std::io::Error> {
    let mut f = match File::create("foo.bar") {
        Ok(f) => f,
        Err(e) => return Err(e),
    };
    match write!(f, "Hello, world!\n") {
        Ok(_) => Ok(()),
        Err(e) => return Err(e),
    }
}

fn main() {
    say_hello().unwrap();
}

Too verbose. Thankfully, we can do shorter. First, the second match is completely useless, we can just return the Result from write! directly. Easy.

The File::create part however is a bit more tricky, as we need to do an early return. Well, Rust provides an operator for that: ?. It is basically the match seen above, but compacted into a single character. So our code above now reads:

fn say_hello() -> Result<(), std::io::Error> {
    let mut f = File::create("foo.bar")?;
    write!(f, "Hello, world!\n")
}

Much shorter, but still handing every error case and passing it up in the stack. Also, like in C++ for example, objects with destructors (Drop trait) will be destroyed with the early return, clearing resources on the way.

The unwrap in main handles all errors coming from the say_hello function in a single place. But that still triggers a panic!. You can also match at this level to print the error cleanly. But Rust has another small trick up its sleeve to ease that too: you are perfectly allowed to make main return a Result, and it will automatically take care of printing the error and exiting with an error status code for you.

fn main() -> Result<(), std::io::Error> {
    say_hello()
}

Voilà, you have complete error management in your program without having to write much. So while Rust prevents you from just throwing errors away, it also makes it easy to handle them properly.

However, there are rare cases where you do not actually care about a result value, neither its success nor error value. If you just do your call, the compiler will complain that you have an unused Result (rightfully), and once again, you have to actively tell it you do not actually want to use the value. There are two ways of doing that: either you assign it to a _ variable, dropping it instantly, or you use ok to transform it to an Option which will clear the warning.

let _ = say_hello();
say_hello().ok();

I prefer the second solution personally as it makes it more visible you are “OK’ing” an error.

Multiple error types

You have used another function which has a different error type, and you do not know how to manage it. For example:

fn main() -> Result<(), std::io::Error> {
    let n = std::env::var("FOOBAR")?;
    let mut f = File::open(n)?;
    write!(f, "foobar")
}

While std::env::var does return a Result, its error part is std::env::VarError, which is not compatible with std::io::Error. The compiler will argue that it cannot do the conversion from one to another. We need to manage this.

Thankfully, error types are (normally) implementing the std::error::Error trait. We can use that to have a generic error return, using a Box. Let us modify the code a bit:

fn main() -> Result<(), Box<dyn std::error::Error + 'static>> {
    let n = std::env::var("FOOBAR")?;
    let mut f = File::open(n)?;
    write!(f, "foobar")
}

Note that this still does not compile, but we will get back to this later. The error moved to the write! call, meaning it solved the issue for the ? calls. You can notice that you do not have to do any conversion using the ?. The ? operator will automatically call .into() on the error value before returning it, and there is a From conversion to automatically convert any implementor of Error into a Box<dyn Error> for convenience. So as long as you have a compatible type, this will work automagically.

For the remaining error, though, we do not have this automatic conversion. Amusingly, the compiler proposes the following solution:

Ok(write!(f, "foobar")?)

It certainly works: if there is an error, it does the early return automatically, otherwise it extracts the Ok value and puts it in a new Ok with the right type. Certainly works but, in my option, a bit silly. You can just make the into call by hand:

write!(f, "foobar").map_err(Into::into)

It is a bit longer to write though, but personally I find it clearer to have into written rather than a wild Ok which does not hint at the error conversion happening.

Note however that while using dyn Error is very practical, it can be more adapted to make your own error type and provide conversions from the “lower” types into yours that you use everywhere, especially for libraries. We will see how to do that in a few paragraphs.

Making your own errors

Sometimes, you want to emit errors on your own. Rust also makes you able to do that, with varying degrees of complexity depending on your needs. The most simple is to use basic strings. Rust provides ways of turning strings into full-fledged errors for your convenience.

fn main() -> Result<(), Box<dyn std::error::Error + 'static>> {
    Err("Something's wrong".into())
}

Once again, a From implementation makes the conversion using into() trivial. Actually, there are From implementation for &str, String and even Cow, so you are covered in all string cases.

Since our type supports into(), it means it also works with ?:

fn main() -> Result<(), Box<dyn std::error::Error + 'static>> {
    let v: Result<(), &'static str> = Err("Aieee");
    println!("{:?}", v?);
    
    Ok(())
}

You can see the different Result type (on the E side) between v and the return of main: ? does the conversion automatically.

Since you can then use Result<T, &str> transparently, it also means that it is easy to make Option::ok_or or Result::map_err pretty easily:

fn main() -> Result<(), Box<dyn std::error::Error + 'static>> {
    let v: Option<()> = None;
    v.ok_or("Ouch")?;
    Ok(())
}

Implementing Error

Using strings as error is good for quick programming, but if you want to be more robust, you will prefer to make your own, full-fledged error type. To do that, you need to implement the Error type from the standard library. This is a very basic one to start with:

use std::fmt;

struct Failure;

impl fmt::Display for Failure {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "Something's wrong")
    }
}

impl fmt::Debug for Failure {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "Something's wrong")
    }
}

impl std::error::Error for Failure {}

You can use the Failure value as an error anywhere, even with the automatic conversion to Box<dyn Error> if needed, or use it as a E for the return value of main. It all works seamlessly.

Error requires you to implement both Display and Debug so your error can be inspected: you can use them to display basic or complex information at your will. For reference, the value printed by main’s return comes from Debug. You can of course put values in your structure, and use them for the error messages to give more information. You can also use an enum.

Wrapping errors

As you make your own error type, you may want to use it as a wrapper for other types of error that can happen. Let’s broaden our type so it can also represent errors of std::io::Error type that are common in the standard library.

We can write a From implementation to make the conversion to our type possible:

impl From<std::io::Error> for Failure {
    fn from(_: std::io::Error) -> Failure {
        Failure
    }
}

And voilà, we can now use ? to convert from std::io::Error to Failure automatically and use our own type everywhere. However, we are wasting precious information that we could keep. Let us modify our type so it can represent both our own emitted errors and the I/O errors while keeping information, leveraging an enum.

enum Failure {
    Own,
    Io(std::io::Error),
}

impl From<std::io::Error> for Failure {
    fn from(inner: std::io::Error) -> Failure {
        Failure::Io(inner)
    }
}

However, we are still not using this information (the compiler will gently inform you that you never use your field), and the error message on runtime is not any more informative. Naturally, we can just modify our printing functions to do so:

impl fmt::Debug for Failure {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            Failure::Own => write!(f, "Something's wrong"),
            Failure::Io(e) => write!(f, "IO Error: {:?}", e),
        }
    }
}

If you try it now, you will see that you can see the inner error. But there is a better way to do so. Rust planned that errors can bubble up and generalize along the way, and provides a mechanism specifically to represent that. In the Error trait, there is an optional method to implement, source, made explicitly to give a source error to the current one, cascading down. Let us implement that:

impl std::error::Error for Failure {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Failure::Own => None,
            Failure::Io(inner) => Some(inner),
        }
    }
}

Note however that Rust’s implementation of the main return does not use this information (it only relies on Debug implementation), but some other libraries managing errors do take it into account. You can also use it if you write your own generic error handler.

Wrapping up

We have seen some important points about error handling in Rust:

All of this is one of the most basic foundations of Rust and what makes code written in it more robust. Even if you willingly ignore an error with unwrap, it will not silently fail with weird side effects but actually crash your program, and finding uses of these methods across a codebase is easy, compared to having to check that every call to open is followed by a if (fd == -1).