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 a Go program, we might have the function return two values: one of the element type, which will hold the answer if there is one, and a second bool
value which will be true
if there’s an answer and false
to indicate that there isn’t:
func first(list []int) (int, bool) {
That’s not terrible, and it’s the standard API idiom in Go for this situation. In Rust, though, there’s a way to do it with just a single value: we can return an Option
.
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...
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!