Rust error handling is perfect actually
Things are always going wrong, as I’m sure you’ve noticed, and that applies to our programs too. Sometimes when a function call asks a question, there’s no answer to return—either because some error happened, or because the correct answer is, simply, “no results”.
When there’s no answer
So, as a matter of good API design, what should we do in these cases?
For example, suppose we’re trying to write a function
first
, that returns the first element of a given list. If
there’s at least one element in the list, there’s no problem. But what
if the list is empty?
In my book The Secrets of Rust:
Tools, I show you how to use the built-in types Option
and Result
to design friendly, easy-to-use, idiomatic Rust
APIs. It turns out that practically every function we ever write in Rust
either uses or produces Option
and Result
values, so they’re important to know about. Let’s take a look.
In our “first element of an empty list” example, the Rustaceous way
to solve this problem is to have the function return an
Option
of whatever the list element type is:
fn first(list: &[i32]) -> Option<i32> {
Options
As you probably guessed, an Option
type indicates that
there may or may not be an answer. The return value from this function
can be one of two things—two variants. It can either be
None
, meaning “no data to return”, or it can be
Some(x)
, meaning “the result is x
”.
It’s now up to the caller to decide what to do. We could use a
match
expression to check whether or not there’s an
answer:
match first(&list) {
Some(x) => println!("The first element is {x}"),
None => println!("No result"),
}
if let
expressions
Commonly, though, we just want to do something if the option is
Some
, but nothing otherwise. It would be annoying to write
an empty match
arm for the None
case, and
fortunately we don’t have to.
Instead, we can use an if let
expression to execute some
code only if the option is Some
, and otherwise just
continue:
if let Some(x) = first(&list) {
println!("The first element is {x}")
}
// moving on...
Conversely, if we want to do something (like bail out of the
function) when the option is None
, we can use the
let ... else
form:
let Some(x) = first(&list) else {
println!("Looks like the list is empty!");
return
}
// do something with `x`...
The ?
operator
Sometimes if the answer is None
, there’s nothing else
useful we can do, so it’s best to just return from the function straight
away. We could do this explicitly with match
or
if let
, but there’s a better way.
We can simply propagate the None
value back to
our caller, by appending the question mark operator
(?
) to it:
fn first_plus_1(list: &[i32]) -> Option<i32> {
Some(first(list)? + 1)
}
Here, if the value of first(list)
is Some
,
then we add 1 to it and return the answer as Some
. On the
other hand, if first(list)
instead returns
None
, then the ?
operator short-circuits this
function and automatically returns None
as its
answer. Neat!
unwrap
/ expect
If we can pretty much guarantee, because of the program’s internal
logic, that there must be Some
answer, we can
enforce that using the unwrap
method:
fn first_plus_1_or_die(list: &[i32]) -> i32 {
.unwrap() + 1 // panics if list is empty
first(list)}
Calling unwrap
is a big move, though. It means the
program will crash with a very rude error if first(list)
is
ever None
:
thread 'main' panicked at src/main.rs:10:17:
called `Option::unwrap()` on a `None` value
Ouch! We can make that error message slightly more informative by
using expect
instead of unwrap
:
.expect("list should not be empty") first(list)
The name is a little confusing: expect
doesn’t mean
“expect the result to be this string”, it means “if the result is
None
, panic with this message”.
But, since good programs don’t panic, and neither do good
programmers, it’s very rare that using unwrap
or
expect
is actually the right thing to do. Usually, we
should either use match
and handle the None
case explicitly, or propagate the Option
using
?
.
Results
As handy as Option
is for signalling when there’s no
answer, it doesn’t give us any way to tell the caller why there
isn’t an answer. With a function like first
, it’s fairly
obvious, so we don’t need to explain. But with a function that can fail
for many different reasons, it’s useful to be able to distinguish
between them.
That’s where Rust’s Result
type comes in. Just like
Option
, a Result
can be one of two possible
variants. It can be either Ok(x)
, meaning “the answer is
x
”, or it can be Err(e)
, meaning “couldn’t get
the answer because error e
happened”.
Here’s how we might define a function that returns a
Result
:
fn sqrt(n: f64) -> Result<f64, String> {
if n >= 0.0 {
Ok(n.sqrt())
} else {
Err("can't take square root of negative number".into())
}
}
Handling Result
values
Again, we can deal explicitly with the two possibilities using a
match
expression:
match sqrt(-5.7) {
Ok(x) => println!("The answer is {x}"),
Err(e) => println!("Whoops: {e}"),
}
Or we can use ?
to propagate any error back to our own
caller:
let answer = sqrt(9.0)?;
Here, if the result is Ok
, then we assign the answer to
answer
and continue. If it’s Err
, though, the
?
operator causes this function to return the
error, provided that its return type is also some kind of
Result
.
Error-only results
Sometimes the function’s job is just to do something, so
there’s no actual answer. But maybe there can still be an error, so in
that case we’d use a Result
where the Ok
variant doesn’t contain anything:
fn print_sqrt(x: i32) -> Result<(), String> {
let answer = sqrt(x)?;
println!("{answer}");
Ok(())
}
The Rust type ()
just means, in effect, “nothing goes
here”. So in this example the print_sqrt
function either
returns Ok(())
, meaning “everything went fine”, or,
implicitly, some string indicating an error (“can’t take square root of
negative number”).
Optionality and resultitude
Some languages let you ignore possible errors altogether, automatically propagating them as exceptions, and crashing the program if they’re not handled somewhere. Other languages, like Go, make error handling explicit, at the expense of a certain amount of boilerplate code to check and handle errors everywhere they can happen.
Rust’s solution, on the other hand, is rather elegant. Returning a
single Option
or Result
from a function
indicates that the answer can be “no data”, or an error. That
“optionality” or “resultitude”, if you like, is part of the
answer, and it can be passed around our program from place to place, or
stored and retrieved, right along with the data it applies to.
Sooner or later, we’ll want to extract the actual answer, if present, and that’s the point where we have to deal with the possibilities of errors or non-answers. Rust gives us the choice to deal with it right away, or defer it for later, but we have to confront the issue at some point in our program. We can’t just ignore it and hope for the best.
Type checking is better than hope
And, since Option
and Result
are distinct
types, Rust can detect at compile time when we’re failing to
properly address issues of optionality and resultitude.
If you try to use an Option<i32>
as though it were
a plain old i32
value, for example, Rust will swiftly
puncture your unwarranted optimism:
let answer: i32 = first(&list);
--- ^^^^^^^^^^^^ expected `i32`, found `Option<i32>`
Which is completely reasonable, and we know what to do instead: use
match
or ?
to deal with the None
case, just as we do with Result
values.
The fact that Rust can catch issues of forgetfulness like this for us
is helpful, and the Option
and Result
types
are a very appealing feature of the language.
In practice, a lot of our code will be about handling errors and “no
data” situations, so having dedicated types and the ?
shorthand to deal with them is a real boost to programmer happiness.
Here’s to Rust!
And you can read more about it in my early access book, The Secrets of Rust: Tools!