Error handling
Error handling is essential to writing robust software. Rust has chosen a model for error handling that emphasizes correctness.
Many programming languages use exceptions to communicate errors. In some way, exceptions are a kind of hidden return value: a function can either return the value it declares it will return, or it can throw an exception.
Rust deliberately chose not to do this, and rather uses return types. This
ensures that it is always clearly communicated which failure modes a function
has, and failure handling does not use a different channel. It also forces
programmers to handle errors, at least to some degree: a fallible function
returns a Result<T, E>
, and you have to either handle the error (with a
match
statement), or propagate it up with a ?
.
In some ways, this is only partially true. Rust does have a kind of exception,
through the panic!()
and .unwrap()
mechanism. However, the difference is
that these are generally only used for unrecoverable errors.
Part of the reason that doing this is ergonomic in Rust is because Rust has great syntactical support for pattern matching. This is not the case for many other languages, which is partially why exceptions were created and remain in use.
Communicating Failures in Rust
Rust has three principal methods of communicating failures. In the order of utility, this is what they are:
- Missing data: Rust has the
Option<T>
type, which can communicate if something is missing. Generally, this is not an error. For example, when you look up a value in a map, it will return eitherNone
orSome(T)
. - Recoverable errors: Rust has the
Result<T, E>
type, which can either contain your data asOk(T)
, or contain an error asErr(E)
. - Unrecorverable errors: Panics are the Rust way to express an error that cannot be recovered from. This is perhaps the closest thing Rust has to exceptions. These are generated when invariants are invalid, or when memory cannot be allocated. When they are encountered, a backtrace is printed and your program is aborted, although there are some ways to catch them if need be.
Rust also has ways to convert between these types of errors. For example, if a missing key in a map is to be treated as an error, you can write:
#![allow(unused)] fn main() { // get user name, or else return a user missing error let value = map.get("user").ok_or(Error::UserMissing)?; }
If an error is unrecoverable (or perhaps, you are prototyping some code and
chose not to properly handle errors yet), then you can turn a Err(T)
into
a panic using unwrap()
or expect()
.
#![allow(unused)] fn main() { let file = std::fs::read_to_string("file.txt").unwrap(); }
Panics in Rust
We can’t talk about error handling in Rust without mentioning panicing. They are a way to signify failures that cannot reasonable be recovered from. Panics are not a general way to communicate errors, they are a method of last resort.
There are different ways to trigger panics in Rust. Commonly, using panics is used when writing prototype code, because you want to focus on the code and defer implementing error handling when the code works.
For example, when you write some code which traverses a data structure, you
might defer implementing the functionality for all edge cases. You can do this
by using the todo!()
macro, which will trigger a panic if called.
#![allow(unused)] fn main() { fn test_value(value: &Value) -> bool { match value { Value::String(string) => string.len() > 0, Value::Number(number) => number > 0, Value::Map(map) => todo!(), Value::Array(array) => todo!(), } } }
Using catch_unwind(), you can catch panics. This might be useful if you use libraries that might panic.
#![allow(unused)] fn main() { std::panic::catch_unwind(|| { panic!("oops!"); }); }
While it is possible to catch panics with
catch_unwind()
,
but this is not recommended, has a performance penalty and does not work across
an FFI boundary. Panics are considered unrecoverable errors, and catching them
only works on a best-effort basis, on supported platforms.
Catching panics can be useful for development. For example, when you implement
a backend with an API, it can be useful to use todo!()
statements in the code
and catch panics in your request handler, so that your backend does not
terminate when you hit something that isn’t implemented yet.
Production applications should generally never panic, and if they do it should result in the default behaviour, which is the application aborting.
The Result
type
In general, functions in Rust are fallible use the Result
return type to
signify this.
If you have a common error type that you use in your application, then it is
possible to make an alias of the Result
type that defaults to your error
type, but allows you to override it with a different error type if needed:
#![allow(unused)] fn main() { type Result<T, E = MyError> = Result<T, E>; }
When you do this, Result<String>
will resolve to Result<String, MyError>
.
However, you can still write Result<String, OtherError>
to get a specific.
Your custom error type is only used as the default when you don’t specify any
other type.
The Error
trait
In general, all error types in Rust implement the Error
trait. This
trait allows for getting a simple textual description of an error, information
about the source of the error.
If you create custom error types, you should implement this trait on them. There are some common libraries that help with doing this.
Libraries for custom error types
Rust comes with some libraries, which can help you integrate into the Rust error system. On a high level, these libraries fall into one of two categories:
- Custom error types: these libraries allow you to define custom error types,
by implementing the
Error
trait and any neccessary other traits. A common pattern is to define an error type, which is an enumeration of all possible errors your application (or this particular function) may produce. These libraries often also help you by implementing aFrom<T>
implementation for errors that your error type wraps. - Dealing with arbitrary errors: In some cases, you want to be able to handle
arbitrary errors. If you are writing a crate which is to be used by others,
this is generally a bad idea, because you want to expose the raw errors to
consumers of your library. But if you are writing an application, and all you
want to do is to render the error at some point, it is usually beneficial to
use some library which has the equivalent of
Box<dyn Error>
and lets you not worry about defining custom error types. Often times, these libraries also contain functionality for pretty-printing error chains and stack traces.
In general, if you write a crate that is to be used as a library by other crates, you should be using a library which allows you to define custom error types. You want the users of your crate to be able to handle the different failure modes, and if the failure modes change (your error enums change), you want to force them to adjust their code. This makes the failure modes explicity.
If you write an application (such as a command-line application, a web application, or any other code where you are not exposing the errors in any kind of API), then using the latter kind of error-handling library is appropriate. In this case, all you care about is reporting errors and metadata (where they occured) to an end-user.
Thiserror
The thiserror crate is a popular
crate for defining custom structured errors. It helps you to implement the
Error
trait for your custom error types.
Imagine you have an application that uses an SQLite database to store data and properties. Every query to the database returns some custom error type of the database library. However, you want consumers of your crate to be able to differentiate between different error cases.
For example:
#![allow(unused)] fn main() { #[derive(thiserror::Error, Debug)] pub enum MyError { #[error(transparent)] Io(#[from] std::io::Error), #[error("user {0:} not found")] UserNotFound(String), } }
The crate is specifically useful for implementing your own structured error types, or for composing multiple existing error types into a wrapper enum.
By writing wrapper enums, you are also able to refine errors, for example classifying errors you receive from an underlying database.
Anyhow
The anyhow crate gives you the
ability to work with dynamic and ad-hoc errors. It exports the anyhow::Error
type, which can capture any Rust error.
use anyhow::Error; fn main() -> Result<(), Error> { let data = std::fs::read_to_string("file.txt")?; Ok(()) }
The anyhow crate also has a Result
alias, which defaults to using its
Error type.
This library is very useful for when you are writing an application that uses multiple libraries, and you don’t want to inspect or handle the errors explicitly. Rather, you can use anyhow’s Error type to pass them around and render them to the user.
Eyre
Miette
Reading
The definitive guide to Rust error handling (archived) by Angus Morrison
Angus walks through the basics of error handling in Rust. He explains the
Error
trait, and when to use boxed versions of it to pass error around. He
shows how it can be downcast into concrete error types, and how anyhow’s Error
type can be used instead. He explains structured error handling by implementing
custom types.
Chapter 9: Error Handling by The Rust Programming Language
Error handling in Rust: a comprehensive tutorial by Eze Sunday
Rust Error Handling: thiserror, anyhow, and When to Use Each (archived) by Momori Nakano
Error Handling in Rust: A Deep Dive by Luca Palmieri
Error Handling in a Correctness-Critical Rust Project by Tyler Neely
Three kinds of Unwrap by zk
Designing Error Types in Rust Libraries (archived) by Sven Kanoldt
Why Use Structured Errors in Rust Applications? by Dmitrii Aleksandrov
Error Handling in Rust (archived) by Andrew Gallant
Designing error types in Rust (archived) by Roman Kashitsyn