Rust: Results, Options and Combinators
Mar 1, 2020
4 minute read

As I started to write Rust code, I found myself regularly using the match syntax. This comes up due to Rust’s error handling pattern, specifically the Result and Option types. Personally, I found that writing matches got old quite quickly. Rust is a concise language in general, so the match-blocks feels verbose, and I start to feel uncomfortable with the indented blocks of code that often end up inside the match.

While I’m very conscious of the paradigm of verbose code often being easier to read than concise code, there are a few tools provided by Rust that once you are comfortable with the idea behind matches, can make your code more concise while (hopefully) remaining readable.

Step One: Panicking

Of course the simplest way to avoid match blocks is to simply ignore that an error might have occurred (Result), or that you might not have something when you think you do (Option). This can be done by calling unwrap or expect. If an Option has a value or a Result is OK, the value or result is returned. Otherwise Rust panics.

Similar is the expect function, which does the same thing but allows you to wave to the user before their program blows up (or maybe write a message to stderr).

But I general you don’t really want to deliberately panic. It sounds bad. It looks bad. It’s bad.

Step Two: The ? operator

The first and most obvious improvement on panicking is the ? syntax, which is used to evaluate a Result, proceed if OK or return the Result up the call stack if an error occurred. This is well documented and common, but is worth a quick example.

So we go from this:

fn write_to_file(value: &str) -> io::Result<()> {
    // Match to see if the file was created

    let mut file = match File::create("file.txt") {
           Ok(f) => f,
           Err(e) => return Err(e),
    };

    // Only handle the error case if the file write failed

    if let Err(e) = file.write_all(value.as_bytes()) {
        return Err(e)
    }

    Ok(())
}

To this:

fn write_to_file(value: &str) -> io::Result<()> {
    let mut file = File::create("file.txt")?;
    file.write_all(value.as_bytes())?;

    Ok(())
}

Step Three: Combinators

The ? syntax is great, but it starts to become less useful, particularly when you want to respond to error cases rather than pass them up the stack, or when you’re mixing Options and Results, or different types of Results. However in many of these cases, there are combinators that can really help, a few examples of which are covered below.

Turning an Option into a Result

A common situation is evaluating an Option which effectively defines the result of a function. I.e. if the option is None, then the function should return. For example:

fn get_language_name_by_id(id: i64) -> Result<&'static str, String> {
    let result = match LANGUAGES.get(id as usize) {
        Some(value) => value,
        None => return Err(format!("No language found for id: {}", id).to_string()),
    };

    Ok(result)
}

Here, a useful combinator is ok_or(), which effectively turns the Option into a Result, and allows you to specify the error message:

fn get_language_name_by_id(id: i64) -> Result<&'static str, String> {
    let result = LANGUAGES.get(id as usize)
                          .ok_or(format!("No language found for id: {}", id)
                          .to_string())?;

    Ok(result)
}

Yield a default value in case of None

Similarly, another nice tool is the unwrap_or combinator that returns a default value when an Option is None:

fn get_language_name_by_id(id: i64) -> Result<&'static str, String> {
    let result = LANGUAGES.get(id as usize).unwrap_or(&UNKNOWN_LANGUAGE_NAME);

    Ok(result)  
}

If at first you don’t succeed

Note that you can also use unwrap_or on a Result to “have another go” in case of an error. Here, if the initial file creation fails, we attempt to create the file with another name (terrible looking code I know, but sometimes real-world examples are tricky to come up with):

fn write_to_file(value: &str) -> io::Result<()> {
    let mut file = File::create("log.txt").unwrap_or(File::create("log01.txt")?);
    file.write_all(value.as_bytes())?;

    Ok(())
}

Closure versions of combinators

Some combinators, including those above also have an _else version, so ok_or_else, unwrap_or_else, etc. These do the same thing as their counterparts, but take a closure instead of a value. The important detail with these is that the logic in the closures are lazily-evaluated. Thus, were you to perform any serious memory allocation or processing in the error cases, these are a useful option to avoid that unless necessary:

fn get_language_name_by_id(id: i64) -> Result<&'static str, String> {
    let result = LANGUAGES
        .get(id as usize)
        .unwrap_or_else(|| return &UNKNOWN_LANGUAGE_NAME);

    Ok(result)
}

Thanks for reading! Please share this post if you found it useful, check out my other posts, and of course, consider buying me a coffee!


comments powered by Disqus