Error handling is hard

rusts error handling is substantially different from other languages. For a quick comparison let’s look at a python example:

try:
    # code that fails
except:
    # what happens when code fails

This approach, although intuitive, leaves some important flaws. Namely:

  • Unhandled errors may occur
  • Errors are propagated implicitly, not explicitly, which can lead to some unwanted behavior
  • If your try-block ends up containing multiple parts of code that could fail you have to handle each of them separately

How rust handles errors

If you have used rust before you’ll already know what comes up. The two main types for error handling in rust are Result and Option. The latter technically doesn’t count as an error type as it just represents the case where an operation may return nothing (i.e. NULL in C, None in python, etc.). The real error type is Result.

The result type is defined as follows1:

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

That is it. That is rusts error handling type. Of course it is a bit more complicated than that with certain methods and so on, but the pure definition is just four lines.

Understanding this type is not so hard: The type is an enum with two variants: Ok and Err for success and failure respectively. The types T and E refer to the types wrapped within the variants in case data is returned. The T type is for the expected data and the E type is the error type (in the definition these two are just generics).

But how does error handling work now? Let’s look at a simple example function2:

pub fn open<P: AsRef<Path>>(path: P) -> Result<File, std::io::Error> {

This is the open method on the File type in rusts standard library. The method (or more precisely associated function) should return a File on success but will return a std::io::Error on failure. Because we use rust we wrap these two types into a Result<T, E> and the caller has to handle both cases.

Here we can denote two important differences between rust and other languages:

  1. Error types are the same as normal types (always, no exceptions)
  2. Errors must be handled

How to deal with errors

Rust provides a bunch of syntactic sugar to deal with errors and results. I will only focus on the three I find most useful.

basic error handling

The most basic error handling is using the .unwrap() method. This method always returns the successful type T and never the error type E. If .unwrap() is called on an error variant the process panics and exits with an error message. A similar method may be used if you want to give the user at least a bit more context. In that case you can use the .expect() method which behaves similar to the .unwrap() method but prints a custom error message passed as the only argument to .expect().

match statements

In rust, a match statement is similar to switch statements in other languages. It allows you to deal with different variants on enums (and other similar types like integers). For results you can use the following approach:

match some_func() {
    Ok(n) => do_something(n),
    Err(e) => handle_error(e),
}

Here you just handle both cases and extract the wrapped type using the match statement.

if let

Another approach, if the result is of type Result<(), E> (i.e. the successful state returns nothing) would be using if let:

if let Err(e) = some_func() {
    handle_error(e);
}

Propagating errors

Another very important technique for error handling is propagation. By this we mean passing on an error from a previously failed function to the next. Say for example we had a function which parses a file. This function will open up a file, which can fail. Instead of handling the error in the parsing function we can pass the error to the caller of the parsing method unchanged. This is very common practice as it leads to cleaner and more robust code. So how do you propagate an error in rust?

The simple case

Say you had a simple function like this:

fn do_with_file(path: PathBuf) -> Result<String, std::io::Error> {}

Here we assume, after having read the file the function cannot fail on it’s own so we only need a single error type.

Let’s look at an example implementation:

fn do_with_file(path: PathBuf) -> Result<String, std::io::Error> {
    let contents = match std::fs::read_to_string(path) {
        Ok(n) => n,
        Err(e) => {
            return Err(e);
        }
    };
    // ...
}

Here we use a match statement to handle the error and just return early, if an error variant is encountered. However, we can apply a bit of syntactic sugar to this using the ? operator:

fn do_with_file(path: PathBuf) -> Result<String, std::io::Error> {
    let contents = std::fs::read_to_string(path)?;
    // ...
}

Both of these examples are functionally identical. The question mark operator just unwraps the result and propagates any encountered error.

The more complex case

Error handling is not always as simple as just dealing with a single error type. A simple example would be to read an input file and parse it. There can be two errors happening here: an IO error while opening the file and a parsing error (which in turn could be subdivided further). As an example, I will use code from my rsweb project.

In this project there exists the following function to read an HTTP request from any sort of stream3:

pub fn read<T>(reader: &mut T) -> Result<HTTPRequest, HTTPRequestParsingError>
where
    T: Read

Now in this case, there are two kinds of errors that can occur:

  • an IO error during reading
  • a parsing error while parsing the request

This of course makes it harder to propagate the error cleanly. At least on the surface. Taking a look at the function signature again, we can see that there is one error type HTTPRequestParsingError. Let’s take a look at its definition:

pub enum HTTPRequestParsingError {
    NetError(std::io::Error),
    MethodMissing,
    PathMissing,
    InvalidMethod,
    InvalidHeader,
}

Solving the problem with multiple errors is quite simple as you can see. You can just add another variant which wraps the original error. No need to write a combined type. But how do you pass on the error? Well you can use match statements to manually set the specific type, but this isn’t very nice, neither is it scalable for very large error types. So how does rsweb do it? We can look at one example:

pub fn read<T>(reader: &mut T) -> Result<HTTPRequest, HTTPRequestParsingError>
where
    T: Read
{
    // ...
    let n = reader.read(&mut tmp_buf)?;
    // ...
}

Huh. So we can still use the question mark operator, even though, the types don’t match? reader.read() has std::io::Error as its error type while the function has HTTPRequestParsingError as its error type.

Well, turns out the question mark operator is a lot smarter. It’s match statement actually looks something more like this:

match do_something() {
    Ok(n) => n,
    Err(e) => {
        return Err(Into::into(e));
    }
}

This means, that rust will automatically convert the error returned by do_something() to the error type the caller understands using the Into trait.

The From and Into traits in rust are the main traits for conversion which cannot fail. They both provide a single method, from and into respectively. And importantly: implementing the From trait automatically implements the Into trait for convenience sake.

With this knowledge we can look at one excerpt of code from rsweb again4:

impl From<std::io::Error> for HTTPRequestParsingError {
    fn from(value: std::io::Error) -> Self {
        HTTPRequestParsingError::NetError(value)
    }
}

Because of this trait implementation, even using the question mark operator on a different error type is possible.

Closing thoughts

In summary, rusts error handling is great for similar reasons as to why other parts of rust are great. Namely: A great type system and amazing syntax sugar go a long way in making errors bearable. This actually goes far beyond what I provided here. This is just a simple observation that I made while using rust and having to deal with more complex errors. Logan Smith made a great video about a few other advanced topics with rusts error types (like the amazing .flatten() method on iterators over results or options).


  1. From the rust stdlib documentation: link ↩︎

  2. From the rust stdlib documentation: link ↩︎

  3. From the rsweb source: link ↩︎

  4. From the rsweb source: link ↩︎