Skip to Content
Course content

74: Error Handling Crates: anyhow and thiserror

Click on the "Edit" button in the top corner of the screen to edit your slide content.

By now, you've probably spent a fair amount of time wrestling with Result and the Error trait. If you've built anything larger than a script, you've likely felt the friction: you either spend half your time writing boilerplate From implementations to convert one error type to another, or you lazily map everything to a String and lose all your type safety.

In the real world, we solve this using two crates: thiserror and anyhow. The golden rule I follow is this: use thiserror when you're building a library (where the caller needs to react to specific error variants), and use anyhow when you're building an application (where you mostly just want to report the error and crash or log it).

Let's build a small configuration loader to see this in action. We want to read a config.toml file and ensure it contains a required "version" key.

Defining structured errors with thiserror

Since the logic for loading a config is "domain logic," I want it to be precise. I don't want the caller to just get a generic "something went wrong" message; they need to know if the file was missing or if the content was malformed. This is where thiserror shines—it lets us define a custom enum without writing the Display or Error traits by hand.

use thiserror::Error;

#[derive(Error, Debug)]
pub enum ConfigError {
    #[error("could not read config file: {0}")]
    Io(#[from] std::io::Error),

    #[error("config file is missing the required 'version' key")]
    MissingVersion,

    #[error("invalid version format: {0}")]
    InvalidVersion(String),
}

fn load_config(path: &str) -> Result {
    let content = std::fs::read_to_string(path)?; // The #[from] handles the conversion from io::Error
    
    if !content.contains("version =") {
        return Err(ConfigError::MissingVersion);
    }
    
    Ok(content)
}

Notice that #[from] attribute on the Io variant. That's the magic. It automatically implements From<std::io::Error> for ConfigError, meaning the ? operator just works.

The friction of strict types in the application layer

Now, let's try to use this in our main function. This is where I usually make my first mistake. I tend to try and keep everything "pure" and use my custom error type everywhere.

fn main() -> Result<(), ConfigError> {
    let config = load_config("config.toml")?;
    println!("Loaded: {}", config);
    Ok(())
}

This works fine... until I decide to add a second step to my program, like creating a log directory using std::fs::create_dir. Suddenly, main is returning a Result<(), ConfigError>, but create_dir returns a std::io::Error. Now I'm stuck. I have to go back to my ConfigError enum and add a new variant just to handle a directory creation error that has absolutely nothing to do with the config file logic. It's tedious and pollutes my domain model.

Smoothing things over with anyhow

This is exactly why anyhow exists. In the application layer (the "glue" code), we don't actually care about the specific type of error—we just want to propagate it up to the user. anyhow::Result is essentially a type-erased wrapper that can hold any error that implements std::error::Error.

Let's refactor main to use anyhow. I'll keep load_config exactly as it is, because that logic should remain portable and precise.

use anyhow::{Context, Result};

fn main() -> Result<()> {
    // We can wrap the call with .context() to add a high-level explanation
    let config = load_config("config.toml")
        .context("Failed to initialize application configuration")?;
    
    println!("Loaded: {}", config);

    // Now, this io::Error just "fits" into anyhow::Result without 
    // needing to modify our ConfigError enum.
    std::fs::create_dir("logs")
        .context("Could not create logs directory")?;

    Ok(())
}

The .context() method is the killer feature here. Instead of just getting "No such file or directory," the user sees: Failed to initialize application configuration: could not read config file: No such file or directory. It creates a causal chain of errors that makes debugging a breeze.

To summarize my workflow: thiserror for the "what" (the specific failure modes of a component), and anyhow for the "where" (the context of where that failure happened in the app).




📋 Practical Task

Build a Robust System Health Checker

Your task is to build a small CLI tool that checks the health of a system by reading a "status" file and verifying a "heartbeat" timestamp. You will need to combine both thiserror and anyhow.

  • The Library Part: Create a HealthError enum using thiserror. It should have variants for IoError (using #[from]), InvalidTimestamp (with a String field), and SystemOverloaded.
  • The Logic: Write a function check_health(path: &str) -> Result<bool, HealthError>. It should read the file; if the file contains the word "CRITICAL", return SystemOverloaded; if it doesn't contain a digit, return InvalidTimestamp.
  • The App Part: In main, use anyhow::Result. Call check_health and use .context() to provide a user-friendly message. Also, attempt to write the result of the check to a file called health_log.txt using std::fs::write, allowing anyhow to handle the potential IO error without modifying your HealthError enum.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.