Error Handling

There are no exceptions

Rust has two ways of indicating errors:

  • Returning a value
  • Panicking

Returning a value

fn parse_header(data: &str) -> bool {
    if !data.starts_with("HEADER: ") {
        return false;
    }

    true
}

It would be nice if we could return data as well as ok, or error...

Foretold enums strike back! 🤯

Remember these? They are very important in Rust.

#![allow(unused)]
fn main() {
enum Option<T> {
    Some(T),
    None,
}

enum Result<T, E> {
    Ok(T),
    Err(E)
}
}

I can't find it

If you have an function where one outcome is "can't find it", we use Option:

#![allow(unused)]
fn main() {
fn parse_header(data: &str) -> Option<&str> {
    if !data.starts_with("HEADER: ") {
        return None;
    }
    Some(&data[8..])
}
}

Note:

It's so important, it is special-cased within the compiler so you can say None instead of Option::None, as you would with any other enum.

That's gone a bit wrong

When the result of a function is either Ok, or some Error value, we use Result:

#![allow(unused)]
fn main() {
enum MyError {
    BadHeader
}

// Need to describe both the Ok type and the Err type here:
fn parse_header(data: &str) -> Result<&str, MyError> {
    if !data.starts_with("HEADER: ") {
        return Err(MyError::BadHeader);
    }
    Ok(&data[8..])
}
}

Note:

It's so important, it is special-cased within the compiler so you can say Ok and Err instead of Result::Ok and Result::Err, as you would with any other enum.

Handling Results by hand

You can handle Result like any other enum:

#![allow(unused)]
fn main() {
use std::io::prelude::*;

fn read_file(filename: &str) -> Result<String, std::io::Error> {
    let mut file = match std::fs::File::open("data.txt") {
        Ok(f) => f,
        Err(e) => {
            return Err(e);
        }
    };
    let mut contents = String::new();
    if let Err(e) = file.read_to_string(&mut contents) {
        return Err(e);
    }
    Ok(contents)
}
}

Handling Results with ?

It is idiomatic Rust to use ? to handle errors.

#![allow(unused)]
fn main() {
use std::io::prelude::*;

fn read_file(filename: &str) -> Result<String, std::io::Error> {
    let mut file = std::fs::File::open("data.txt")?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    Ok(contents)
}
}

Note:

This was added in Rust 1.39.

The ? operator will evaluate to the Ok value if the Result is Ok, and it will cause an early return with the error value if it is Err. It will also call .into() to perform a type conversion if necessary (and if possible).

What kind of Error?

You can put anything in for the E in Result<T, E>:

#![allow(unused)]
fn main() {
fn literals() -> Result<(), &'static str> {
    Err("oh no")
}

fn strings() -> Result<(), String> {
    Err(String::from("oh no"))
}

fn enums() -> Result<(), Error> {
    Err(Error::BadThing)
}

enum Error { BadThing, OtherThing }
}

Using String Literals as the Err Type

Setting E to be &'static str lets you use "String literals"

  • It's cheap
  • It's expressive
  • But you can't change the text to include some specific value
  • And your program can't tell what kind of error it was

Using Strings as the Err Type

Setting E to be String lets you make up text at run-time:

  • It's expressive
  • You can render some values into the String
  • But it costs you a heap allocation to store the bytes for the String
  • And your program still can't tell what kind of error it was

Using enums as the Err Type

An enum is ideal to express one of a number of different kinds of thing:

#![allow(unused)]
fn main() {
/// Represents the ways this module can fail
enum Error {
    /// An error came from the underlying transport
    Io,
    /// During an arithmetic operation a result was produced that could not be stored
    NumericOverflow,
    /// etc
    DiskFull,
    /// etc
    NetworkTimeout,
}
}

Enum errors with extra context

An enum can also hold data for each variant:

#![allow(unused)]
fn main() {
/// Represents the ways this module can fail
enum Error {
    /// An error came from the underlying transport
    Io(std::io::Error),
    /// During an arithmetic operation a result was produced that could not
    /// be stored
    NumericOverflow,
    /// Ran out of disk space
    DiskFull,
    /// Remote system did not respond in time
    NetworkTimeout(std::time::Duration),
}
}

The std::error::Error trait

Helper Crates

So, people created helper crates like thiserror

use thiserror::Error;

#[derive(Error, Debug)]
pub enum DataStoreError {
    #[error("data store disconnected")]
    Disconnect(#[from] io::Error),
    #[error("the data for key `{0}` is not available")]
    Redaction(String),
    #[error("invalid header (expected {expected:?}, found {found:?})")]
    InvalidHeader { expected: String, found: String },
    #[error("unknown data store error")]
    Unknown,
}

Something universal

Exhaustively listing all the ways your dependencies can fail is hard.

One solution:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let _f = std::fs::File::open("hello.txt")?; // IO Error
    let _s = std::str::from_utf8(&[0xFF, 0x65])?; // Unicode conversion error
    Ok(())
}

Anyhow

The anyhow crate gives you a nicer type:

fn main() -> Result<(), anyhow::Error> {
    let _f = std::fs::File::open("hello.txt")?; // IO Error
    let _s = std::str::from_utf8(&[0xFF, 0x65])?; // Unicode conversion error
    Ok(())
}

Note:

  • Use anyhow if you do not care what error type your function returns, just that it captures something.
  • Use thiserror if you must design your own error types but want easy Error trait impl.

Panicking

The other way to handle errors is to generate a controlled, program-ending, failure.

  • You can panic!("x too large ({})", x);
  • You can call an API that panics on error (like indexing, e.g. s[99])
  • You can convert a Result::Err into a panic with .unwrap() or .expect("Oh no")