Errors (part 1)

In Rust, errors are returned through the Result type as we've seen in previous chapters. Errors are 'normal' structs. Nevertheless, there is a convention to implement certain traits on types that represent an error: the std::fmt::Displayand std::error:Error traits from the standard library.

A typical error implementation could look like this:

type SampleResult<T> = std::result::Result<T, SampleError>;

#[derive(Debug, Clone)]
struct SampleError {
    pub message: String,
}

impl SampleError {
    fn new(msg: &str) -> Self {
        SampleError {
            message: msg.to_string(),
        }
    }
}

impl std::fmt::Display for SampleError {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        write!(f, "Sample error: {}", self.message)
    }
}

impl std::error::Error for SampleError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        None
    }
}

fn failable_function() -> SampleResult<u32> {
    Err(SampleError::new("oops"))
}

fn main() {
    if let Err(err) = failable_function() {
        println!("Error: {err}");
    }
}

It could be useful to convert (low-level) errors into your custom error type as you propagate errors up in the chain. Rust has a "std::convert::From" trait that facilitates this conversion.

You can use the From trait to write conversion code for all types of structs, not only errors.

use std::fs::read_to_string;
use std::io;

type SampleResult<T> = std::result::Result<T, SampleError>;

#[derive(Debug, Clone)]
struct SampleError {
    pub message: String,
}

impl std::fmt::Display for SampleError {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        write!(f, "Sample error: {}", self.message)
    }
}

impl std::error::Error for SampleError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        None
    }
}

impl std::convert::From<io::Error> for SampleError {
    fn from(io_err: io::Error) -> Self {
        SampleError {
            message: io_err.to_string(),
        }
    }
}

fn read_from_file() -> SampleResult<String> {
    match read_to_string("non_existing_file.txt") {
        Err(err) => Err(err.into()),
        Ok(content) => Ok(content),
    }
}

fn main() {
    if let Err(err) = read_from_file() {
        println!("Error: {err}");
    }
}

The into() function in the above code is the inverse of the from() function. The compiler is clever enough to let you use both of these, whilst only implementing the From trait. We could have also used Err(SampleError::from(err)).

The match function() { Err(err) => ... , Ok(c) => ... } pattern used in the above sample is a common way to check results. The Err(err) path can be used to quickly exit the function in case of an error.

Dealing with boilerplate

The above error handling code is quite verbose. There are popular crates that can help reduce the amount of boilerplate code you need to write: anyhow and thiserror. These two crates follow a different approach to error handling, and which one to pick depends on your use case.

anyhow makes it extremely easy to write concise error handling code, but is less useful for capturing and dealing with upstream errors. thiserror is more verbose, but allows you to define your own error types and implement the Error trait on them.

I'd suggest you use anyhow when you are writing a client application, a CLI tool, or some other binary where you are not exposing your error types to other libraries or applications. Use thiserror when you are writing a library and want to define your own error types.

The anyhow crate

The anyhow crate is a popular choice for error handling in Rust. It allows you to write concise error handling code without having to define your own error types. Here's how you can use it:

use anyhow::{anyhow, Result};

fn fallible_function() -> Result<u32> {
    Err(anyhow!("oops"))
}

fn main() -> Result<()> {
    if let Err(err) = fallible_function() {
        println!("Error: {err}");
    }
    Ok(())
}

With the anyhow! macro, you can create an error with a custom message. The Result type is a type alias for anyhow::Result<T>, which is a Result type that can hold any error type. This makes it easy to return errors from functions without having to define your own error types.

The thiserror crate

The thiserror crate can help reduce the boilerplate code of creating custom error types. Here's how you can use it:

use thiserror::Error;

#[derive(Error, Debug)]
enum SampleError {
    #[error("An error occurred: {0}")]
    Custom(String),
    #[error("An IO error occurred: {0}")]
    Io(#[from] std::io::Error),
    #[error("Some other error occurred: {0}")]
    Other(String),
}

fn fallible_function() -> Result<u32, SampleError> {
    Err(SampleError::Custom("oops".to_string()))
}

fn fallible_io_operation() -> Result<u32, SampleError> {
    let _ = std::fs::File::open("non_existent_file.txt")?;
    Ok(42)
}

fn main() -> Result<(), SampleError> {
    if let Err(err) = fallible_function() {
        match err {
            SampleError::Custom(msg) => println!("Custom error: {}", msg),
            SampleError::Io(err) => println!("IO error: {}", err),
            SampleError::Other(msg) => println!("Other error: {}", msg),
        }
    }

    fallible_io_operation()?;
    Ok(())
}

As you can see, the thiserror crate allows you to define your own error types and implement the Error trait on them with minimal boilerplate code. It allows "source" errors to be converted into your custom error type, and provides a convenient way to match on different error variants.

We'll revisit the error topic in a bit. First, I'd like to cover the structure of a Rust project.

Reference material