Beginner-Intermediate Rust Error Handling

This article assumes you've read most of the The Rust Programming Language book.

Pre-Introduction§

There are mainly two styles of error handling you'll need using Rust, or for that matter, most languages:

  • Application level error handling, where you don't care as much about handling errors than propagating them.
  • Library level error handling, where it's imperative that you have neat errors that you can return.

The rest of the article is written with the second perspective. What follows is not definitive practice - I'd actually instead recommend anyhow for the first need, and thiserror for a less-opinionated library for the second need, along with snafu for a more opinionated version.

What follows is more of an evolution of practices than anything else.

Introduction§

Let's take the example of a command-line app we're developing. Any good command-line app needs a configuration file to toggle behavior. Let's write some code for that.

lib.rs

pub mod config;

config.rs

pub struct Config {
    pub name: String,
}

To write a function that returns Config, we'll have to:

  1. Open the file that's passed to us (take a std::fs::Path) and read it (with the std::fs::File struct).

  2. Parse the file as JSON and deserialize into Config. (We'll use the excellent serde and serde_json libraries)

To use serde and serde_json, we'll add them to our dependencies.

cargo.toml

[dependencies]
serde = { version = "1.0.137", features = ["derive"] }
serde_json = "1.0.81"

read_file§

We first write a read_file function:

fn read_file (path: &Path) -> Result<Vec<u8>, std::io::Error> {
    let mut f = File::open(&path)?;
    let mut buf = Vec::new();
    f.read_to_end(&mut buf)?;
    Ok(buf)
}

It uses the ? operator to "early-return" in the case of an error. In this case, we use io::Error in the return type since that's what both the functions we call use.

config_file§

We then write our config_file function, which calls read_file to get a buffer it can then deserialize with serde_json into Config.

Before that, we'll need to derive Deserialize for Config in order to use serde_json

#[derive(Deserialize)]
pub struct Config {
  pub name: String    
}

We'll define a custom error type which is an enum of both of our possible errors.

#[derive(Debug)]
pub enum ConfigError {
    FileError(io::Error),
    DeserializeError(serde_json::Error)
}

This enum pattern has some notable limitations, and in extreme cases can form an anti-pattern. We'll discuss that later.

We use this as the Err type in our return value.

pub fn config_file (path: &Path) -> Result<Config, ConfigError> {
    let buf = read_file(path)?;
    let conf = serde_json::from_slice(&buf)?; // deserializes bytes into Config
    Ok(conf)
}

The From trait for Errors§

The above snippet doesn't quite work: read_file returns a Result with an error type of io::Error, and serde_json::from_slice returns serde_json::Error.

We can't use the ? operator here to return an Error since the function returns a Result with the error type ConfigError. One way we can make this work is using the From<T> trait for our error types.

1#[derive(Debug)]
2pub enum ConfigError {
3 FileError(io::Error),
4 DeserializeError(serde_json::Error)
5}
6
7impl From<serde_json::Error> for ConfigError {
8 fn from(e: serde_json::Error) -> Self {
9 Self::DeserializeError(e)
10 }
11}
12
13impl From<io::Error> for ConfigError {
14 fn from(e: io::Error) -> Self {
15 Self::FileError(e)
16 }
17}

Great!

Encoding Relevant Information in ConfigError§

Next, let's try using our interface.

main.rs

use error_demo::config::config_file;

fn main () {
	let conf = config_file("config.json").unwrap();
	println!("{}", conf.name);
}

This errors. No surprise, because I haven't really made a config.json file yet.

thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: FileError(Os { code: 2, kind: NotFound, message: "No such file or directory" })', src/main.rs:4:43
stack backtrace:
   0: rust_begin_unwind
             at /rustc/7737e0b5c4103216d6fd8cf941b7ab9bdbaace7c/library/std/src/panicking.rs:584:5
   1: core::panicking::panic_fmt
             at /rustc/7737e0b5c4103216d6fd8cf941b7ab9bdbaace7c/library/core/src/panicking.rs:143:14
   2: core::result::unwrap_failed
             at /rustc/7737e0b5c4103216d6fd8cf941b7ab9bdbaace7c/library/core/src/result.rs:1749:5
   3: core::result::Result<T,E>::unwrap
             at /rustc/7737e0b5c4103216d6fd8cf941b7ab9bdbaace7c/library/core/src/result.rs:1065:23
   4: error_demo::main
             at ./src/main.rs:4:13
   5: core::ops::function::FnOnce::call_once
             at /rustc/7737e0b5c4103216d6fd8cf941b7ab9bdbaace7c/library/core/src/ops/function.rs:227:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
The terminal process "cargo 'run', '--package', 'error-demo', '--bin', 'error-demo'" terminated with exit code: 101.

Note that the backtrace requires the RUST_BACKTRACE=1 environment variable to be set.

This is unfortunately a rather obscure error.

  • While it gives us the line the panic originated on (src/main.rs:4:43), it cannot tell the source of the error itself.
  • It informs us that "a File was NotFound", but unfortunately we can't see which file we failed to open.

We could resolve the second by redefining our ConfigError enum to instead be a struct and contain the error type along with the path.

#[derive(Debug)]
enum ConfigErrorType {
    FileError(std::io::Error),
    DeserializeError(serde_json::Error)
}

pub struct ConfigError {
    path: PathBuf
    err: ConfigErrorType
}

While we could have a more efficient type for path (&'a Path) in our error type, we skip that for simplicity's sake.

Our nice ? work breaks down here. Instead,

  • read_file and serde_json::from_slice returns incompatible error types.

  • we first coerce them to the same type using .map_err(|e| e.into()) - this uses the Into<T> trait, which is automatically implemented for types that implement From<T> in the opposite direction.

    From<std::io::Error> is implemented for ConfigErrorType implies that Into<ConfigErrorType> is implemented for std::io::Error.

  • next we use a closure we've build earlier which has "closed over" (or captured) the path to convert into a PathBuf and construct a ConfigError from a the passed ConfigErrorType.

The map_err methods accepts anything that implements the FnOnce trait, which includes closures.

pub fn config_file (path: &Path) -> Result<Config, ConfigError> {
    let construct_config_error = |err| ConfigError {path: path.to_path_buf(), err};

    let buf = read_file(path).map_err(|e| e.into()).map_err(construct_config_error)?;
    let conf = serde_json::from_slice(&buf).map_err(|e| e.into()).map_err(construct_config_error)?;
    Ok(conf)
}

If you're feeling particularly clever, you could even write Into::into (qualified: std::convert::Into::into) for the first map_err.

Rerunning get_context, we get a somewhat better error message.

thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: FileError(FileError { path: "config.json", err: Os { code: 2, kind: NotFound, message: "No such file or directory" } })', src/main.rs:4:51

Even nicer errors with std::fmt§

Did you know? There's a trait responsible for printing out the struct representation in the above error, and that's std::fmt::Debug. That's what unwrap calls. If we permit ourselves to a little dive in the guts of std::result:

impl<T, E> Result<T, E> {
    //--snip--
    pub fn unwrap(self) -> T
    where
        E: fmt::Debug,
    {
        match self {
            Ok(t) => t,
            Err(e) => unwrap_failed("called `Result::unwrap()` on an `Err` value", &e),
        }
    }
    //--snip--
    fn unwrap_failed(msg: &str, error: &dyn fmt::Debug) -> ! {
        panic!("{}: {:?}", msg, error)
    }
    //--snip--
}

The panic function uses std::fmt::Debug to format the error value with the {:?} format arg, while it calls another trait, std::fmt::Display to format the msg.

While Debug intends to provide a programmer-friendly debug representation, Display is meant for an end user's eyes and thus usually nicer to look at.

A good comparison between the two is their handling for std::string::Strings:

fn main () {
    let s = String::from("A string with weird\tspacing and \nnewlines\n");
    println!("{}", s);
    println!("{:?}", s);
}

The Display implementation prints:

A string with weird		spacing and 
newlines

The Debug implementation prints:

"A string with weird\t\tspacing and \nnewlines\n"

Implementing Debug for our Error type§

You might have seen that we've just plonked a #[derive(Debug)] on all of our enums and structs so far, but we can also implement it ourselves.

Deriving Debug on a struct or enum requires all the elements inside to also implement Debug. Practically all the standard library types implement Debug, and implementing it on your own is usually as simple as #[derive(Debug)].

Types that implement Debug don't necessarily implement Display, however. std::path::PathBuf is a good example - since it can represent arbitrary byte sequences, it must necessarily represent them in a debuggable form, or risk messing up a terminal with binary data.

Before messing with implementing Debug, let's first implement Display.

impl Display for ConfigError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match &self.err {
            ConfigErrorType::FileError(e) => f.write_fmt(format_args!("could not open file {:?} due to error {:?}", self.path, e)),
            ConfigErrorType::DeserializeError(e) => f.write_fmt(format_args!("could not deserialize file {:?} due to error {:?}", self.path, e)),
        }
    }
}

Next, we can use the above implementation for implementing Debug as well.

// Forward to display implementation
impl std::fmt::Debug for ConfigError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        <ConfigError as Display>::fmt(self, f)
    }
}

I like doing it this way for two reasons:

  • It makes removing the custom Debug impl much cleaner - delete the above code, then plonk #[derive(Debug)].
  • Implementing it on Display makes it clear that the error is meant to be user-facing, and it just so happens that the user-facing implementation exposes enough detail to allow for debugging.

Now, we get a nicer error message.

thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Could not open file "config.json" due to error Os { code: 2, kind: NotFound, message: "No such file or directory" }'

Turning on Easy Mode with the Error trait§

The standard library actually has a std::error::Error trait. It requires that the type implement Display and Debug before attempting to implement Error. For a lot of errors, this could possibly be enough.

impl std::error::Error for ConfigError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        let err: &(dyn std::error::Error + 'static) = match &self.err {
            ConfigErrorType::FileError(f) => f,
            ConfigErrorType::DeserializeError(d) => d
        };
        Some(err)
    }
}

Both the snafu and thiserror crates give you some nice macros to derive the Error trait for you.

And then, a lot of error stuff becomes painless if you start boxing 'em up, since return propagation is as simple as a ? if your functions all return the same type. In exchange, for inspecting errors you have to downcast them, like so:

type Result<T> = std::result::Result<T, Box<dyn Error>>;

fn read_file(path: &Path) -> Result<File> {
    Ok(File::open(path)?)
}

pub fn call_read_file () {
    let err = read_file("path-that-doesnt-exist".as_ref());
    if let Err(e) = err {
        let io_err: Box<std::io::Error> = e.downcast().unwrap();
        if matches!(io_err.kind(), ErrorKind::NotFound) {
            eprintln!("File not found!");
        }
    }
}

Ok(File::open(path)?) looks super funky. It's equivalent to File::open(path).map_err(|e| e.into()), or File::open(path).map_err(Into::into) if you're feeling fancy. (It's the same because the ? operator tries to convert the Error type of the operand into the return Error type of type of the function. In this case, since any error that implements Error can be converted to Box<dyn Error> through the From/Into traits, we're good to go.)

The matches! macro returns a boolean; true if the given expression matches any of the given patterns. (uses the refutable pattern syntax)

Common Error Patterns§

So far, we'd looked at designing our error as first an enum containing underlying error types, and then a struct with some context and an enum containing underlying error types.

While all errors are the same things in essence, there is a variety of methods in expressing them.

Boxing the error§

One common method would be minimizing error size by Boxing it.

To quote serde_json v1.0.81,

src/error.rs

15/// This type represents all possible errors that can occur when serializing or
16/// deserializing JSON data.
17pub struct Error {
18 /// This `Box` allows us to keep the size of `Error` as small as possible. A
19 /// larger `Error` type was substantially slower due to all the functions
20 /// that pass around `Result<T, Error>`.
21 err: Box<ErrorImpl>,
22}

This section no longer exists§

There used to be a section here, but then I found matklad (Aleksey Kladov)'s excellent article "Study of std::io::Error". Since I doubt if I could write it better, I'm linking to it instead.

Further Reading§

I've referenced a few error-handling libraries at the start of the article - if you'd be interested in hearing more about a comparison between them, do let me know!



As always, feel free to write to me to point out an error, suggest a topic, or just say hi!