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
config.rs
To write a function that returns Config, we'll have to:
-
Open the file that's passed to us (take a
std::fs::Path) and read it (with thestd::fs::Filestruct). -
Parse the file as JSON and deserialize into Config. (We'll use the excellent
serdeandserde_jsonlibraries)
To use
serdeandserde_json, we'll add them to our dependencies.cargo.toml
[] = { = "1.0.137", = ["derive"] } = "1.0.81"
read_file§
We first write a read_file function:
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
Configin order to useserde_json
We'll define a custom error type which is an enum of both of our possible errors.
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.
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
2 3 4 5
6
7 8 9 10 11
12
13 14 15 16 17
Great!
Encoding Relevant Information in ConfigError§
Next, let's try using our interface.
main.rs
use config_file;
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=1environment 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.
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_fileandserde_json::from_slicereturns incompatible error types. -
we first coerce them to the same type using
.map_err(|e| e.into())- this uses theInto<T>trait, which is automatically implemented for types that implementFrom<T>in the opposite direction.From<std::io::Error>is implemented forConfigErrorTypeimplies thatInto<ConfigErrorType>is implemented forstd::io::Error. -
next we use a closure we've build earlier which has "closed over" (or captured) the path to convert into a
PathBufand construct aConfigErrorfrom a the passedConfigErrorType.
The
map_errmethods accepts anything that implements theFnOncetrait, which includes closures.
If you're feeling particularly clever, you could even write
Into::into(qualified:std::convert::Into::into) for the firstmap_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:
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:
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
Debugon a struct or enum requires all the elements inside to also implementDebug. Practically all the standard library types implementDebug, 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.
Next, we can use the above implementation for implementing Debug as well.
// Forward to display implementation
I like doing it this way for two reasons:
- It makes removing the custom
Debugimpl much cleaner - delete the above code, then plonk#[derive(Debug)]. - Implementing it on
Displaymakes 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.
Both the
snafuandthiserrorcrates give you some nice macros to derive theErrortrait 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> = Result;
Ok(File::open(path)?)looks super funky. It's equivalent toFile::open(path).map_err(|e| e.into()), orFile::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 implementsErrorcan be converted toBox<dyn Error>through theFrom/Intotraits, 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.
17 18 19 20 21 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!
I'm looking for Summer 2025 internships. Feel free to write to me to point out an error, suggest a topic, or just say hi!