Error handling is hard
rusts error handling is substantially different from other languages. For a quick comparison let’s look at a python example:
try:
# code that fails
except:
# what happens when code fails
This approach, although intuitive, leaves some important flaws. Namely:
- Unhandled errors may occur
- Errors are propagated implicitly, not explicitly, which can lead to some unwanted behavior
- If your
try
-block ends up containing multiple parts of code that could fail you have to handle each of them separately
How rust handles errors
If you have used rust before you’ll already know what comes up. The two main
types for error handling in rust are Result
and Option
. The latter
technically doesn’t count as an error type as it just represents the case where
an operation may return nothing (i.e. NULL
in C, None
in python, etc.).
The real error type is Result
.
The result type is defined as follows1:
pub enum Result<T, E> {
Ok(T),
Err(E),
}
That is it. That is rusts error handling type. Of course it is a bit more complicated than that with certain methods and so on, but the pure definition is just four lines.
Understanding this type is not so hard: The type is an enum with two variants:
Ok
and Err
for success and failure respectively. The types T
and E
refer to the types wrapped within the variants in case data is returned. The
T
type is for the expected data and the E
type is the error type (in the
definition these two are just generics).
But how does error handling work now? Let’s look at a simple example function2:
pub fn open<P: AsRef<Path>>(path: P) -> Result<File, std::io::Error> {
This is the open
method on the File
type in rusts standard library. The
method (or more precisely associated function) should return a File
on
success but will return a std::io::Error
on failure. Because we use rust we
wrap these two types into a Result<T, E>
and the caller has to handle both cases.
Here we can denote two important differences between rust and other languages:
- Error types are the same as normal types (always, no exceptions)
- Errors must be handled
How to deal with errors
Rust provides a bunch of syntactic sugar to deal with errors and results. I will only focus on the three I find most useful.
basic error handling
The most basic error handling is using the .unwrap()
method. This method
always returns the successful type T
and never the error type E
. If
.unwrap()
is called on an error variant the process panics and exits with an
error message. A similar method may be used if you want to give the user at
least a bit more context. In that case you can use the .expect()
method which
behaves similar to the .unwrap()
method but prints a custom error message
passed as the only argument to .expect()
.
match statements
In rust, a match statement is similar to switch
statements in other
languages. It allows you to deal with different variants on enums (and other
similar types like integers). For results you can use the following approach:
match some_func() {
Ok(n) => do_something(n),
Err(e) => handle_error(e),
}
Here you just handle both cases and extract the wrapped type using the match statement.
if let
Another approach, if the result is of type Result<(), E>
(i.e. the
successful state returns nothing) would be using if let
:
if let Err(e) = some_func() {
handle_error(e);
}
Propagating errors
Another very important technique for error handling is propagation. By this we mean passing on an error from a previously failed function to the next. Say for example we had a function which parses a file. This function will open up a file, which can fail. Instead of handling the error in the parsing function we can pass the error to the caller of the parsing method unchanged. This is very common practice as it leads to cleaner and more robust code. So how do you propagate an error in rust?
The simple case
Say you had a simple function like this:
fn do_with_file(path: PathBuf) -> Result<String, std::io::Error> {}
Here we assume, after having read the file the function cannot fail on it’s own so we only need a single error type.
Let’s look at an example implementation:
fn do_with_file(path: PathBuf) -> Result<String, std::io::Error> {
let contents = match std::fs::read_to_string(path) {
Ok(n) => n,
Err(e) => {
return Err(e);
}
};
// ...
}
Here we use a match statement to handle the error and just return early, if an
error variant is encountered. However, we can apply a bit of syntactic sugar to
this using the ?
operator:
fn do_with_file(path: PathBuf) -> Result<String, std::io::Error> {
let contents = std::fs::read_to_string(path)?;
// ...
}
Both of these examples are functionally identical. The question mark operator just unwraps the result and propagates any encountered error.
The more complex case
Error handling is not always as simple as just dealing with a single error
type. A simple example would be to read an input file and parse it. There can
be two errors happening here: an IO error while opening the file and a parsing
error (which in turn could be subdivided further). As an example, I will use
code from my rsweb
project.
In this project there exists the following function to read an HTTP request from any sort of stream3:
pub fn read<T>(reader: &mut T) -> Result<HTTPRequest, HTTPRequestParsingError>
where
T: Read
Now in this case, there are two kinds of errors that can occur:
- an IO error during reading
- a parsing error while parsing the request
This of course makes it harder to propagate the error cleanly. At least on the
surface. Taking a look at the function signature again, we can see that there
is one error type HTTPRequestParsingError
. Let’s take a look at its definition:
pub enum HTTPRequestParsingError {
NetError(std::io::Error),
MethodMissing,
PathMissing,
InvalidMethod,
InvalidHeader,
}
Solving the problem with multiple errors is quite simple as you can see. You can just add another variant which wraps the original error. No need to write a combined type. But how do you pass on the error? Well you can use match statements to manually set the specific type, but this isn’t very nice, neither is it scalable for very large error types. So how does rsweb do it? We can look at one example:
pub fn read<T>(reader: &mut T) -> Result<HTTPRequest, HTTPRequestParsingError>
where
T: Read
{
// ...
let n = reader.read(&mut tmp_buf)?;
// ...
}
Huh. So we can still use the question mark operator, even though, the types
don’t match? reader.read()
has std::io::Error
as its error type while the
function has HTTPRequestParsingError
as its error type.
Well, turns out the question mark operator is a lot smarter. It’s match statement actually looks something more like this:
match do_something() {
Ok(n) => n,
Err(e) => {
return Err(Into::into(e));
}
}
This means, that rust will automatically convert the error returned by
do_something()
to the error type the caller understands using the Into
trait.
The From
and Into
traits in rust are the main traits for conversion which
cannot fail. They both provide a single method, from
and into
respectively.
And importantly: implementing the From
trait automatically implements the
Into
trait for convenience sake.
With this knowledge we can look at one excerpt of code from rsweb again4:
impl From<std::io::Error> for HTTPRequestParsingError {
fn from(value: std::io::Error) -> Self {
HTTPRequestParsingError::NetError(value)
}
}
Because of this trait implementation, even using the question mark operator on a different error type is possible.
Closing thoughts
In summary, rusts error handling is great for similar reasons as to why other
parts of rust are great. Namely: A great type system and amazing syntax sugar
go a long way in making errors bearable. This actually goes far beyond what I
provided here. This is just a simple observation that I made while using rust
and having to deal with more complex errors. Logan Smith made a great
video about a few other advanced
topics with rusts error types (like the amazing .flatten()
method on
iterators over results or options).