17 January 2023
In my last article, Rust: Panic, I talked about how to work with unrecoverable errors in Rust. What about recoverable errors? Those are much more common, after all.
Not surprisingly, Rust has a sophisticated system for error handling. At the heart of it is the Result type, defined as follows:
pub enum Result<T, E> {
Ok(T),
Err(E),
}
A function that might return an error should have Result<T, E>
as its return
type, where T is the type that the function normally returns and E is a type that describes the
error if one occurs. There’s a convention that E should implement the std::error::Error trait, but
that’s not enforced by the type system.
What if your function doesn’t return anything in case of success? In that case, you still use
Result, with T set to the empty tuple ()
, also called “unit”.
Let’s take a look at a simple example. We’re using std::fs::read to read a file into memory:
use std::{fs, io};
fn read_file() {
let r: Result<Vec<u8>, io::Error> = fs::read("foo");
match r {
Ok(v) => {
println!("read {} bytes", v.len());
}
Err(e) => {
println!("got error: {}", e);
}
}
}
Result is just a regular enum, so can use match
to distinguish its two variants.
If we get an error we’ll just print it -- you can check the std::io::Error documentation to
verify that it implements the Display trait.
Quite often when a functions encounters an error, we just want to propagate it to the caller. Let’s change our read_file function so it returns the file size instead of printing it. In case of error, it’ll just return the error:
fn file_size() -> Result<usize, io::Error> {
let r: Result<Vec<u8>, io::Error> = fs::read("foo");
match r {
Ok(v) => Ok(v.len()),
Err(e) => Err(e),
}
}
Looks good, but it’s a little verbose. In Rust, the ? operator implements the “if this is an error, return the error” logic, so we can write the function like this:
fn file_size() -> Result<usize, io::Error> {
let v: Vec<u8> = fs::read("foo")?;
Ok(v.len())
}
That’s some sweet syntactic sugar! A single character takes care of checking and correctly
propagating any error that might occur. We can make the code even shorter if we do without the
v
variable:
fn file_size() -> Result<usize, io::Error> {
Ok(fs::read("foo")?.len())
}
The question mark followed by a dot may look a bit odd, but it makes sense: the type of
fs::read("foo")?
is Vec<u8>
, and we’re calling the
len
method on that vector.
Option<T>
and an expression e
of type Option<U>
anywhere in that function, you can use e?
to propagate None
.
Before we take another look at Result, we’ll first take a look at how you can define and return your own error types.
Let’s say you’re writing some kind of parser and you want to return a custom ParseError if the input can’t be parsed. We’ll keep it simple and define an error type with just an error message:
#[derive(Debug)]
struct ParseError {
message: String,
}
Following Rust conventions, we’ll also implement std::error::Error. If you take a look at the documentation, you’ll see that it has four methods, but all of them have default implementations. It also requires the Debug and Display traits. The Debug implementation is already derived, so the main thing we still need to do is to implement Display:
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "parse error: {}", self.message)
}
}
impl std::error::Error for ParseError {}
Using the type is pretty straightforward. For a simple example, let’s say we have a
parse_boolean
function that recognizes true
and false
keywords:
fn parse_boolean(input: &str) -> Result<bool, ParseError> {
match input {
"true" => Ok(true),
"false" => Ok(false),
_ => Err(ParseError {
message: format!("invalid boolean: {}", input),
}),
}
}
When you define an error type for a package or crate,
it’s common to just call it Error
. In this example we might have all the parsing code
in a package parsing
, so a type called Error
would be
parsing.Error
from outside the package -- probably a better choice than the redundant
parsing.ParseError
.
Result has quite a few methods that can be useful in various cases. Here are some of the more common ones, organized by when you’d use them:
is_ok
and is_err
methods return a boolean:
let r: Result<u32, std::io::Error> = Ok(4);
println!("{}", r.is_ok()); // prints true
println!("{}", r.is_err()); // prints false
unwrap
to get the value out of the
Result:
let i: u32 = "123".parse().unwrap();
It’ll panic in case of error.
unwrap_or
to replace errors with a default value. For example,
fs::read_to_string("foo").unwrap_or("<missing>".to_string())
returns either the contents of file foo
or the string <missing>
.
unwrap_or_default
.
fs::read_to_string("foo").unwrap_or_default()
returns either the contents of file foo
or an empty string.
Result<T, E>
where you want to modify the T
value in the Ok
case, and just keep the E
in the Err
case.
Result
has three methods to do that, depending on how you’re going to modify the T:
and_then
for a function that returns a Result<T, E>
map
for a function that returns a T
and
for a fixed Result
value.fn download() -> Result<String, std::io::Error> {
// download file and return filename...
}
fn download_and_read() -> Result<String, std::io::Error> {
download().and_then(|filename| fs::read_to_string(filename))
}
fn download_and_format() -> Result<String, std::io::Error> {
download().map(|filename| format!("download OK: {}", filename))
}
fn download_and_confirm() -> Result<String, std::io::Error> {
download().and(Ok("download OK".to_string()))
}
I have some more things I want to find out about Result, such as how it interacts with iterators. This article getting a bit long, though, so that’ll have to wait for another day…