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::File
struct). -
Parse the file as JSON and deserialize into Config. (We'll use the excellent
serde
andserde_json
libraries)
To use
serde
andserde_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
Config
in 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=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.
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
andserde_json::from_slice
returns 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 forConfigErrorType
implies 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
PathBuf
and construct aConfigError
from a the passedConfigErrorType
.
The
map_err
methods accepts anything that implements theFnOnce
trait, 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::String
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 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
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.
Both the
snafu
andthiserror
crates give you some nice macros to derive theError
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> = 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 implementsError
can be converted toBox<dyn Error>
through theFrom
/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 Box
ing 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!
Feel free to write to me to point out an error, suggest a topic, or just say hi!