Item 3: Prefer Option and Result transforms over explicit match expressions

Item 1 expounded the virtues of enum and showed how match expressions force the programmer to take all possibilities into account. Item 1 also introduced the two ubiquitous enums that the Rust standard library provides:

  • Option<T>: To express that a value (of type T) may or may not be present
  • Result<T, E> : For when an operation to return a value (of type T) may not succeed and may instead return an error (of type E)

This Item explores situations where you should try to avoid explicit match expressions for these particular enums, preferring instead to use various transformation methods that the standard library provides for these types. Using these transformation methods (which are typically themselves implemented as match expressions under the covers) leads to code that is more compact and idiomatic and has clearer intent.

The first situation where a match is unnecessary is when only the value is relevant and the absence of value (and any associated error) can just be ignored:

#![allow(unused)]
fn main() {
struct S {
    field: Option<i32>,
}

let s = S { field: Some(42) };
match &s.field {
    Some(i) => println!("field is {i}"),
    None => {}
}
}

For this situation, an if let expression is one line shorter and, more importantly, clearer:

if let Some(i) = &s.field {
    println!("field is {i}");
}

However, most of the time the programmer needs to provide the corresponding else arm: the absence of a value (Option::None), possibly with an associated error (Result::Err(e)), is something that the programmer needs to deal with. Designing software to cope with failure paths is hard, and most of that is essential complexity that no amount of syntactic support can help with—specifically, deciding what should happen if an operation fails.

In some situations, the right decision is to perform an ostrich maneuver—put our heads in the sand and explicitly not cope with failure. You can't completely ignore the error arm, because Rust requires that the code deal with both variants of the Error enum, but you can choose to treat a failure as fatal. Performing a panic! on failure means that the program terminates, but the rest of the code can then be written with the assumption of success. Doing this with an explicit match would be needlessly verbose:

#![allow(unused)]
fn main() {
let result = std::fs::File::open("/etc/passwd");
let f = match result {
    Ok(f) => f,
    Err(_e) => panic!("Failed to open /etc/passwd!"),
};
// Assume `f` is a valid `std::fs::File` from here onward.
}

Both Option and Result provide a pair of methods that extract their inner value and panic! if it's absent: unwrap and expect. The latter allows the error message on failure to be personalized, but in either case, the resulting code is shorter and simpler—error handling is delegated to the .unwrap() suffix (but is still present):

#![allow(unused)]
fn main() {
let f = std::fs::File::open("/etc/passwd").unwrap();
}

Be clear, though: these helper functions still panic!, so choosing to use them is the same as choosing to panic! (Item 18).

However, in many situations, the right decision for error handling is to defer the decision to somebody else. This is particularly true when writing a library, where the code may be used in all sorts of different environments that can't be foreseen by the library author. To make that somebody else's job easier, prefer Result to Option for expressing errors, even though this may involve conversions between different error types (Item 4).

Of course, this opens up the question, What counts as an error? In this example, failing to open a file is definitely an error, and the details of that error (no such file? permission denied?) can help the user decide what to do next. On the other hand, failing to retrieve the first() element of a slice because that slice is empty isn't really an error, and so it is expressed as an Option return type in the standard library. Choosing between the two possibilities requires judgment, but lean toward Result if an error might communicate anything useful.

Result also has a #[must_use] attribute to nudge library users in the right direction—if the code using the returned Result ignores it, the compiler will generate a warning:

warning: unused `Result` that must be used
  --> src/main.rs:63:5
   |
63 |     f.set_len(0); // Truncate the file
   |     ^^^^^^^^^^^^
   |
   = note: this `Result` may be an `Err` variant, which should be handled
   = note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
   |
63 |     let _ = f.set_len(0); // Truncate the file
   |     +++++++

Explicitly using a match allows an error to propagate, but at the cost of some visible boilerplate (reminiscent of Go):

pub fn find_user(username: &str) -> Result<UserId, std::io::Error> {
    let f = match std::fs::File::open("/etc/passwd") {
        Ok(f) => f,
        Err(e) => return Err(From::from(e)),
    };
    // ...
}

The key ingredient for reducing boilerplate is Rust's question mark operator, ?. This piece of syntactic sugar takes care of matching the Err arm, transforming the error type if necessary, and building the return Err(...) expression, all in a single character:

pub fn find_user(username: &str) -> Result<UserId, std::io::Error> {
    let f = std::fs::File::open("/etc/passwd")?;
    // ...
}

Newcomers to Rust sometimes find this disconcerting: the question mark can be hard to spot on first glance, leading to disquiet as to how the code can possibly work. However, even with a single character, the type system is still at work, ensuring that all of the possibilities expressed in the relevant types (Item 1) are covered—leaving the programmer to focus on the mainline code path without distractions.

What's more, there's generally no cost to these apparent method invocations: they are all generic functions marked as #[inline], so the generated code will typically compile to machine code that's identical to the manual version.

These two factors taken together mean that you should prefer Option and Result transforms over explicit match expressions.

In the previous example, the error types lined up: both the inner and outer methods expressed errors as std::io::Error. That's often not the case: one function may accumulate errors from a variety of different sublibraries, each of which uses different error types.

Error mapping in general is discussed in Item 4, but for now, just be aware that a manual mapping:

pub fn find_user(username: &str) -> Result<UserId, String> {
    let f = match std::fs::File::open("/etc/passwd") {
        Ok(f) => f,
        Err(e) => {
            return Err(format!("Failed to open password file: {:?}", e))
        }
    };
    // ...
}

can be more succinctly and idiomatically expressed with the following .map_err() transformation:

pub fn find_user(username: &str) -> Result<UserId, String> {
    let f = std::fs::File::open("/etc/passwd")
        .map_err(|e| format!("Failed to open password file: {:?}", e))?;
    // ...
}

Better still, even this may not be necessary—if the outer error type can be created from the inner error type via an implementation of the From standard trait (Item 10), then the compiler will automatically perform the conversion without the need for a call to .map_err().

These kinds of transformations generalize more widely. The question mark operator is a big hammer; use transformation methods on Option and Result types to maneuver them into a position where they can be a nail.

The standard library provides a wide variety of these transformation methods to make this possible. Figure 1-1 shows some of the most common methods (rounded white rectangles) that transform between the relevant types (gray rectangles).1 In line with Item 18, methods that can panic! are marked with an asterisk.

The diagram shows mappings between Result, Option and related types.  Gray boxes show types, and white rounded
boxes show methods that transform between types.  Methods that can panic are marked with an asterisk. In the middle are
the Result<T, E> and Option<T> types, with methods ok, ok_or and ok_or_else that convert between them. To one
side of Result<T, E> are the or and or_else methods that transform back to the same type. To one side of
Option<T> are various methods that transform back to the same type: filter, xor, or, or_else and replace.
Across the top and bottom of the diagram are various related types that can covert to or from Result and Option.
For Result<T, E>, the map method reaches Result<T, F>, the map, and and and_then methods reach Result<U, E>,
and the map_or and map_or_else methods reach U, with all of the destinations at the bottom of the diagram.
At the top of the diagram, Result<T, E> maps to Option<E> via err, to E via unwrap_err and expect_err (both
of which can panic), and to T via a collection of methods: unwrap, expect, unwrap_or, unwrap_or_else,
unwrap_or_default (where unwrap and expect might panic).  The E and T types map back to Result<T, E> via the
Err(e) and Ok(t) enum variants.  For Option<T>, the map, and and and_then methods reach Option<U>, and the
map_or and map_or_else methods reach U at the bottom of the diagram. At the top of the diagram, Option<T> maps
to T via the same collection of methods as for Result: unwrap, expect, unwrap_or, unwrap_or_else,
unwrap_or_default (where unwrap and expect might panic).  The T type maps back to Option<T> via the Some(t)
enum; the () type also maps to Option<T> via None.

Figure 1-1. Option and Result transformations

One common situation the diagram doesn't cover deals with references. For example, consider a structure that optionally holds some data:

#![allow(unused)]
fn main() {
struct InputData {
    payload: Option<Vec<u8>>,
}
}

A method on this struct that tries to pass the payload to an encryption function with signature (&[u8]) -> Vec<u8> fails if there's a naive attempt to take a reference:

impl InputData {
    pub fn encrypted(&self) -> Vec<u8> {
        encrypt(&self.payload.unwrap_or(vec![]))
    }
}
error[E0507]: cannot move out of `self.payload` which is behind a shared
              reference
  --> src/main.rs:15:18
   |
15 |     encrypt(&self.payload.unwrap_or(vec![]))
   |              ^^^^^^^^^^^^ move occurs because `self.payload` has type
   |                           `Option<Vec<u8>>`, which does not implement the
   |                           `Copy` trait

The right tool for this is the as_ref() method on Option.2 This method converts a reference-to-an-Option into an Option-of-a-reference:

pub fn encrypted(&self) -> Vec<u8> {
    encrypt(self.payload.as_ref().unwrap_or(&vec![]))
}

Things to Remember

  • Get used to the transformations of Option and Result, and prefer Result to Option. Use .as_ref() as needed when transformations involve references.
  • Use these transformations in preference to explicit match operations on Option and Result.
  • In particular, use these transformations to convert result types into a form where the ? operator applies.

1

The online version of this diagram is clickable; each box links to the relevant documentation.

2

Note that this method is separate from the AsRef trait, even though the method name is the same.