Rust's Result Type is Cool
Beyond the basics with Rust error handling
Table of contents
If you've worked with Rust before, you know how different its error
handling story is from most other languages. The Rust Programming
Language explains
the two primary ways of raising errors, panicking and the Result
type, and how
you can propagate the Result
type with the ?
operator to make recoverable
errors explicit without interfering with the happy path in a certain function.
Once you've got the hang of this system, it can cover a your needs for a long
time, especially since the ?
operator also automatically converts between
error types when there's an available From
impl. You can even use Box<dyn
Error>
as a sort of "supertype" error if you're using a bunch of different
functions that can error out in different ways.
Here, we're reading a configuration file from a path, that at the moment only consists of a single integer.
fn work_with_config_file(path: &str)
-> Result<i32, Box<dyn std::error::Error>> {
// This error is an std::io::Error
let file = std::fs::read_to_string(path)?;
// This one is a ParseIntError
let number = file.parse()?;
// No From impl required!
Ok(number)
}
However, we both know this isn't a particularly "proper" way of doing things.
As you progress using Rust in more advanced situations, this will begin to feel
janky. You should be take advantage of Rust's type system to create your own
Error
types, just like the standard library itself does, so that the eventual
handler of the error far up the chain can deal with its specific type, instead
of just getting a generic boxed Error
with their best option being to
simply print it out for you to sit there and debug.
Unfortunately, this is hard and involves a TON of boilerplate (enter that link at your own discretion).
With more useful types of errors for a larger function, and with more data
and more useful error messages, the size of that code will get even more out
of control. While there are libraries that can implement many of these traits
automatically, it can be a lot of bloat to bring into a project. More broadly,
the concerns here aren't really well-separated - in this case, the
function itself should determine how to convert between the error types, not
some From
impls.
I'll admit to having gotten frustrated with the whole situation and thinking that the system is just fundamentally too much work. And while there's much that can be improved - given there's an entire working group dedicated to fixing it - there are quite a lot of features of the standard library that not a lot of intermediate-level Rustaceans know about that are immensely useful in reducing some of the hassle.
Combinators
The documentation of the Result
type lists
an absolute treasure trove of methods that can be easy to ignore
in the world of unwrap
and ?
, the most notable of which is
map_err
.
fn work_with_config_file(path: &str)
-> Result<i32, ConfigFileError> {
let file = std::fs::read_to_string(path)
.map_err(|e| ConfigFileError::ReadError(e))?;
let number = file.parse()
.map_err(|e| ConfigFileError::ParseError(e))?;
// No From impl required, and no Box either!
Ok(number)
}
You can make this even simpler by just passing in the variant name instead of a
closure to map_err
, since it can be used as a function to create the variant,
like map_err(ConfigFileError::ReadError)?
.
Now the function itself controls how it constructs the error types it returns,
and the error type itself simply defines how to print the error in a friendly
way. From
impls are still appropriate if you find yourself doing this many
times or in multiple different places, but map_err
can allow potentially
complex conversions between error types to happen inline without using match
and a bunch of indent levels.
But this is just the beginning.
and_then
is very useful to chain fallible operations concisely; to group together
logically related operations or perform a series of small operations that would
clutter the code with ?
operators too much otherwise.
fn work_with_config_file(path: &str)
-> Result<i32, ConfigFileError> {
let number = std::fs::read_to_string(path)
.map_err(ConfigFileError::ReadError)
.and_then(|f| f.parse()
.map_err(ConfigFileError::ParseError))?;
// only a single ?
Ok(number)
}
Of course, you've got to be worried about trying to be overly concise and ending
up with a complicated line. and_then
's best use is to be able to easily
refactor out each fallible step into a function and make it all look clean.
fn read_config_file(path: &str)
-> Result<String, ConfigFileError> {
std::fs::read_to_string(path)
.map_err(ConfigFileError::ReadError)
}
fn parse_config_file(file: String)
-> Result<i32, ConfigFileError> {
file.parse()
.map_err(ConfigFileError::ParseError)
}
fn work_with_config_file(path: &str)
-> Result<i32, ConfigFileError> {
// No ? required at all - just directly return
// the Result, since the types match!
read_config_file(path).and_then(parse_config_file)
// .and_then(something_else).and_then(even_more_things) ....
}
Now the reading and parsing steps can be large and hide a lot of implementation details while still allowing the higher level code to work with it seamlessly. [1]
These are the most useful and common combinators, but there are a lot more:
or_else
,
map_or_else
,
as_deref
, etc.
I highly encourage you to fully peruse the documentation of Result
, because
there's a huge variety of incredibly useful operations that can reduce or even
totally eliminate the need to manually match
on errors.
Result ❤️ Option
The final piece of the puzzle that fully unlocked Rust's default error handling
story for me was the fact that while the Option<T>
type is defined separately
for convenience, conceptually, it is best understood as a special case of
Result
, equivalent to Result<T, ()>
.
Because of this, Option<T>
also implements the same useful combinators that
a Result<T, ()>
would have!
and_then
,
map
,
they're all here!
More importantly than even this, there are several convenience methods on both
Option
and Result
that allow you to convert between them seamlessly, making
them highly connected.
ok_or
,
for instance, allows you to add context to a bare None
that you got from
somewhere else.
fn get_first(list: Vec<i32>)
-> Result<i32, String> {
// Vec::get returns an Option<i32>.
list.get(0).ok_or("Empty list!".to_string())
}
You can even use ?
on Option
values in a function that returns Option
,
which is a fact I really wish was more highly advertised.
Conclusion
There are many other places to learn about the extensive error handling story in Rust, written by people much better at programming than me. I especially encourage you to read BurntSushi's error handling guide once you run into the limits of these combinators and want to take an even deeper dive. But I hope that these examples brought your attention to the huge amount of stuff available in the Rust standard library that makes writing simple yet robust Rust code easier than it may appear while first learning. It certainly opened up a whole new world to me when I first learned about them!
If you have any feedback about this post, feel free to comment about it; and if you learned something from it, I'd love to hear it too!
[1]: If you're a functional programming nerd, you might recognize these as kind of like monads. They are, but if you understand those, why are you reading this article? Go cure cancer or something.