Rust: Error Handling

Home · Blog

17 January 2023

In my last article, Rust: Panic, I talked about how to work with unrecoverable errors in Rust. What about recoverable errors? Those are much more common, after all.

The Result type

Not surprisingly, Rust has a sophisticated system for error handling. At the heart of it is the Result type, defined as follows:

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

A function that might return an error should have Result<T, E> as its return type, where T is the type that the function normally returns and E is a type that describes the error if one occurs. There’s a convention that E should implement the std::error::Error trait, but that’s not enforced by the type system.

What if your function doesn’t return anything in case of success? In that case, you still use Result, with T set to the empty tuple (), also called “unit”.

Let’s take a look at a simple example. We’re using std::fs::read to read a file into memory:

use std::{fs, io};

fn read_file() {
    let r: Result<Vec<u8>, io::Error> = fs::read("foo");
    match r {
        Ok(v) => {
            println!("read {} bytes", v.len());
        }
        Err(e) => {
            println!("got error: {}", e);
        }
    }
}

Result is just a regular enum, so can use match to distinguish its two variants. If we get an error we’ll just print it -- you can check the std::io::Error documentation to verify that it implements the Display trait.

Propagating errors

Quite often when a functions encounters an error, we just want to propagate it to the caller. Let’s change our read_file function so it returns the file size instead of printing it. In case of error, it’ll just return the error:

fn file_size() -> Result<usize, io::Error> {
    let r: Result<Vec<u8>, io::Error> = fs::read("foo");
    match r {
        Ok(v) => Ok(v.len()),
        Err(e) => Err(e),
    }
}

Looks good, but it’s a little verbose. In Rust, the ? operator implements the “if this is an error, return the error” logic, so we can write the function like this:

fn file_size() -> Result<usize, io::Error> {
    let v: Vec<u8> = fs::read("foo")?;
    Ok(v.len())
}

That’s some sweet syntactic sugar! A single character takes care of checking and correctly propagating any error that might occur. We can make the code even shorter if we do without the v variable:

fn file_size() -> Result<usize, io::Error> {
    Ok(fs::read("foo")?.len())
}

The question mark followed by a dot may look a bit odd, but it makes sense: the type of fs::read("foo")? is Vec<u8>, and we’re calling the len method on that vector.

The ? operator also works for Option: if you have a function that returns Option<T> and an expression e of type Option<U> anywhere in that function, you can use e? to propagate None.

Custom error types

Before we take another look at Result, we’ll first take a look at how you can define and return your own error types.

Let’s say you’re writing some kind of parser and you want to return a custom ParseError if the input can’t be parsed. We’ll keep it simple and define an error type with just an error message:

#[derive(Debug)]
struct ParseError {
    message: String,
}

Following Rust conventions, we’ll also implement std::error::Error. If you take a look at the documentation, you’ll see that it has four methods, but all of them have default implementations. It also requires the Debug and Display traits. The Debug implementation is already derived, so the main thing we still need to do is to implement Display:

impl fmt::Display for ParseError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "parse error: {}", self.message)
    }
}

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

Using the type is pretty straightforward. For a simple example, let’s say we have a parse_boolean function that recognizes true and false keywords:

fn parse_boolean(input: &str) -> Result<bool, ParseError> {
    match input {
        "true" => Ok(true),
        "false" => Ok(false),
        _ => Err(ParseError {
            message: format!("invalid boolean: {}", input),
        }),
    }
}

When you define an error type for a package or crate, it’s common to just call it Error. In this example we might have all the parsing code in a package parsing, so a type called Error would be parsing.Error from outside the package -- probably a better choice than the redundant parsing.ParseError.

Working with Result values

Result has quite a few methods that can be useful in various cases. Here are some of the more common ones, organized by when you’d use them:

I have some more things I want to find out about Result, such as how it interacts with iterators. This article getting a bit long, though, so that’ll have to wait for another day…