Lesson Details

Error Handling

In Rust there are two major classes of error: Recoverable and Unrecoverable. If the error is recoverable, a function will return a type that you can continue to operate on. If the error is unrecoverable, the program will panic and exit.

Panics

A program can panic explicitly if something goes wrong using a variety of macros. Some examples include:

  • panic!
  • todo!
  • unimplemented!
  • unreachable!
  • An assert_eq! that fails

When writing code that isn’t finished, it can be very useful to use the todo! macro. This is effectively a shorthand for writing panic with a customized error message. When run, if the Avian:Parrot match arm is hit, it will panic with the message "not yet implemented: memory for parrots".

fn main() {
let bird = Avian::Parrot {
name: "Jose".to_string(),
};

let log = match bird {
Avian::Chicken => "squawk",
Avian::Owl => "Who? Who?",
Avian::Parrot { name } => {
todo!("memory for parrots");
}
};

println!("bird says: {log}");
}

#[derive(Debug)]
enum Avian {
Chicken,
Owl,
Parrot { name: String },
}

The other useful part of the todo! macros is that it will short-circuit the type checking, allowing this program to compile. If the todo! didn’t exist in this example, the Parrot match arm would return unit, (), which is incompatible with the &str returned from the other arms. We’ll cover control flow more later, but in the meantime understand that a match expression like this must return the same type from all arms.

Recoverable Errors

The two major types used for dealing with recoverables errors are Option and Result. These are both enums that contain a success value in one variant and an error value in the other.

An Option represents a value that may or may not exist. Rust doesn’t have a concept of null or undefined or other similar values, so Option is how a potentially missing value is represented.

enum Option<T> {
None,
Some(T),
}

A Result can hold any two distinct values in its variants. Result::Ok represents the succesful value while Result::Err represents the error value.

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

Often the error value in a Result is an enum representing multiple things that could have gone wrong, but it is not required to be any specific value and could be a usize for example.

If you do index access on a Vec and you use an index that doesn’t exist, the program will panic. There’s an alternative function called get that will instead return an Option. This means the resulting value will be Option::Some if successful and Option::None if not successful.

fn main() {
let numbers = vec![10, 20, 30, 40];

dbg!(numbers.get(2));
dbg!(numbers.get(11));
numbers[11];
}

This program prints out two values using get before panicking on the direct index access.

[src/main.rs:4:5] numbers.get(2) = Some(
30,
)
[src/main.rs:5:5] numbers.get(11) = None

thread 'main' panicked at src/main.rs:6:12:
index out of bounds: the len is 4 but the index is 11

Similarly a Result is returned by Vec::get_disjoint_mut. get_disjoint_mut returns exclusive references to multiple items in the Vec if it can, otherwise it returns a GetDisjointMutError. The function can give us non-overlapping exclusive references to different items.

use std::slice::GetDisjointMutError;

fn main() {
let mut numbers = vec![10, 20, 30, 40];

match numbers.get_disjoint_mut([0, 1]) {
Ok(values) => {
*values[0] = 101;
*values[1] = 102;
dbg!(numbers);
}
Err(GetDisjointMutError::OverlappingIndices) => {
println!("overlapping indices");
}
Err(GetDisjointMutError::IndexOutOfBounds) => {
println!("index out of bounds");
}
}
}
[src/main.rs:10:13] numbers = [
101,
102,
30,
40,
]

But if we ask for the same item multiple times, we can’t get multiple exclusive references to the same item, and we receive a Result::Err(GetDisjointMutError::OverlappingIndices).

use std::slice::GetDisjointMutError;

fn main() {
let mut numbers = vec![10, 20, 30, 40];

match numbers.get_disjoint_mut([0, 0]) {
Ok(values) => {
*values[0] = 101;
*values[1] = 102;
dbg!(numbers);
}
Err(GetDisjointMutError::OverlappingIndices) => {
println!("overlapping indices");
}
Err(GetDisjointMutError::IndexOutOfBounds) => {
println!("index out of bounds");
}
}
}

Options and Results

Options and Results are so similar its common to convert between the two by dropping or adding an error message.

Given an Option, an error message can be added, converting into a Result using Option::ok_or.

Given a Result, the error message can be dropped using Result::ok.

try?

Dealing with Option and Result is so common that there’s syntax for “return if the value has an error”. This is called the try operator and can be used with both Option and Result.

When writing a function, the return type can be an Option or Result. If the types that we’re using match, such as in the case of numbers.get(4) below, then we can use the ? operator to unwrap the value if its Some and return None otherwise. This function works on slices of length 5 or more, and returns None otherwise.

fn main() {
let numbers = vec![10, 20, 33, 53, 75, 105];
let output = add_10_to_fourth_index(&numbers);
dbg!(output);
}

fn add_10_to_fourth_index(numbers: &[i32]) -> Option<i32> {
let value = numbers.get(4)?;
Some(value + 10)
}

Levels of Error Handling

It’s possible to be as granular as is useful when handling errors in Rust programs. For example, an application-level error in a CLI tool might only care about reporting errors to users.

For application-level error reporting, we can take advantage of one of many error reporting crates. In this case we’ll use anyhow.

cargo add anyhow

anyhow provides a new anyhow::Error type, and a type alias anyhow::Result that uses anyhow::Error as the defult error type. anyhow also implements From for any error. “An error” in this case is any type that implements the standard library Error trait. This means any type that implements Error can be returned using the try operator ? if the function returns anyhow::Result.

Consider the following example. The program uses anyhow::Result as the return type, enabling the handling of many different error values.

The first action that can fail is an attempt to read a numbers.txt file. If the file doesn’t exist, or doesn’t have the right permissions, a Result::Err variant containing an io::Error is returned. We take advantage of anyhow’s ability to add additional context to errors when they happen, before using the try operator to “unwrap or return the error”.

use anyhow::Context;
use std::fs;

fn main() -> anyhow::Result<()> {
let file_contents = fs::read_to_string("numbers.txt")
.context("numbers.txt must exist and be a list of one number per line")?;

let mut total = 0;

for line in file_contents.lines() {
let number = line
.parse::<i32>()
.context("numbers must be i32s")?;
total += number;
}

println!("{total:?}");

Ok(())
}

If the file doesn’t exist, the application prints the error out along with the context we added.

❯ cargo run
Error: numbers.txt must exist and be a list of one number per line

Caused by:
No such file or directory (os error 2)

It is assumed that the numbers.txt is file like the following, containing i32 values. In this case, we have a float value, which will fail to parse.

1
10
200
4.2

Since we’re iterating over each line with a for loop and attempting to parse it into an i32, we can again add context to the potential ParseIntError that will happen when we encounter the float.

Doing application-level error handling this way means that there’s very little additional setup, and we can also still add context to the errors as they happen.

By contrast, it’s often the case in libraries that you want to be more specific about the errors that can be returned. For this we can build our own error types at any granularity. An error type for the entire library crate is a fairly common approach, but you could do an error type for every function if you were ok with the extra overhead of writing a bunch of different error types.

Building our own Errors

Similar to how we saw GetDisjointMutError was an enum with multiple variants, we can construct our own error types.

Typically this is made easier by the crate ecosystem, which can derive implementations of the standard library Error trait for you.

Specifically, thiserror is an extremely common crate to use, since it makes writing error variants easier. There are also additional options like snafu.

Given a hypothetical data_store library crate, a custom error type could look like this.

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,
}

thiserror provides a derive macro and a set a of attributes to make defining errors easier.

In this case we see a variety of error message format strings that can optionally include data from the error variant itself.

It is also the case that we’ll have to interface with other error, such as those from reading a file. thiserror provides a from attribute macro that when used, results in a From implementation that connects the relevant error to our custom error type. In this case that means we can use the try operator on an io::Error returned from operations like read_to_string, if our function returns our custom error type. We wrap the whole io:Error in a new variant of our custom error when this happens, automatically.

fn read_data_file() -> Result<(), DataStoreError> {
let file_contents = fs::read_to_string("numbers.txt")?;

dbg!(file_contents);

Ok(())
}