Skip to Content
Course content

162: When to Use anyhow vs thiserror

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

I've spent a lot of time in the Rust ecosystem, and for a while, I was confused by the overlap between anyhow and thiserror. On the surface, they both seem to do the same thing: make error handling less painful than manually implementing std::fmt::Display and std::error::Error for every single struct. But they solve two fundamentally different problems.

Let's figure this out by building a small configuration loader. I want to read a config.toml file and turn it into a struct.

Starting with the easy way

If I'm just hacking something together or writing a top-level application, I usually reach for anyhow. It's the "I don't really care what the error is, as long as I can report it" crate. Watch how clean this looks:

use anyhow::{Context, Result};
use serde::Deserialize;
use std::fs;

#[derive(Deserialize)]
struct Config {
    port: u16,
}

fn load_config() -> Result<Config> {
    let content = fs::read_to_string("config.toml")
        .context("Failed to read the configuration file")?;
    
    let config: Config = toml::from_str(&content)
        .context("Failed to parse TOML content")?;
    
    Ok(config)
}

I love this. I get to use the ? operator, and the .context() method allows me to wrap the low-level I/O error with a human-readable explanation. If this fails, anyhow bundles everything into a dynamic error type that I can just print out in main(). It's fast, it's flexible, and it gets out of my way.

The wall I hit with opaque errors

But here is where I run into a problem. Imagine that as our app grows, I decide that if the config.toml file is missing, the app shouldn't crash—it should just fall back to a default configuration. However, if the file exists but is corrupted (invalid TOML), I still want the app to crash because that implies a user error.

I try to handle this in main():

fn main() {
    match load_config() {
        Ok(cfg) => println!("Port: {}", cfg.port),
        Err(e) => {
            // Wait... how do I check if this was a "file not found" error?
            // anyhow::Error is basically a trait object (Box<dyn Error>).
            // I can't easily match on it.
        }
    }
}

This is the "Aha!" moment. anyhow is an eraser. It erases the specific type of the error to give us convenience. But now I actually need that type information to make a logic decision. I can't efficiently tell the difference between an std::io::ErrorKind::NotFound and a toml::de::Error once they've been wrapped in anyhow::Error.

Bringing back the type system

This is where thiserror comes in. While anyhow is for consuming errors, thiserror is for defining them. It doesn't provide a flexible result type; instead, it provides a derive macro that does the boring work of implementing the Error trait for my own custom enum.

Let's rewrite the loader using a structured approach:

use thiserror::Error;
use serde::Deserialize;
use std::fs;

#[derive(Error, Debug)]
pub enum ConfigError {
    #[error("Configuration file not found")]
    NotFound(#[from] std::io::Error),
    
    #[error("Configuration syntax error: {0}")]
    InvalidFormat(#[from] toml::de::Error),
}

#[derive(Deserialize)]
struct Config {
    port: u16,
}

fn load_config() -> Result<Config, ConfigError> {
    let content = fs::read_to_string("config.toml")?;
    let config: Config = toml::from_str(&content)?;
    Ok(config)
}

Now, look at what happened. By using #[from], thiserror automatically implements From<std::io::Error> and From<toml::de::Error> for my ConfigError. I still get to use the ? operator, but I've preserved the identity of the error.

Now my main() function can actually be smart:

fn main() {
    match load_config() {
        Ok(cfg) => println!("Port: {}", cfg.port),
        Err(ConfigError::NotFound(_)) => {
            println!("Config missing, using default port 8080");
        },
        Err(e) => {
            eprintln!("Fatal error: {}", e);
            std::process::exit(1);
        }
    }
}

The gold standard pattern

So, when do you use which? After years of this, I've landed on a rule of thumb that I use in almost every production project.

  • Use thiserror in libraries or internal modules. If you are writing a function that another part of your code (or another user) might need to react to programmatically, define a custom error enum. It makes your API honest and predictable.
  • Use anyhow in the "main" application logic. Your main() function, your CLI handlers, or your high-level orchestration code doesn't usually need to recover from errors—it just needs to report them to the user. anyhow is perfect for this.

In fact, the most common pattern is to use both. Your low-level modules return thiserror enums, and your top-level main function returns anyhow::Result. Since anyhow::Error can wrap any type that implements std::error::Error, your structured errors flow upward and get collected into a convenient, printable report at the very end.




📋 Practical Task

Refactoring a User Profile Loader for Granular Error Handling

You have a piece of code that loads a user profile from a database-like mock. Currently, it uses anyhow, which makes it impossible for the caller to distinguish between a "User Not Found" error (which should trigger a registration flow) and a "Database Connection" error (which should trigger a retry).

Your goal: Refactor the following code to use thiserror for the load_user function, while keeping anyhow in main for final reporting.

use anyhow::{Result, Context};

// Mock data
fn mock_db_call(id: u32) -> Result<String, anyhow::Error> {
    if id == 0 {
        return Err(anyhow::anyhow!("Connection timeout"));
    } else if id == 404 {
        return Err(anyhow::anyhow!("User not found"));
    }
    Ok("User: Alice".to_string())
}

fn load_user(id: u32) -> Result<String> {
    mock_db_call(id).context("Failed to load user profile")
}

fn main() {
    let user_id = 404;
    if let Err(e) = load_user(user_id) {
        println!("Error occurred: {}", e);
        // TODO: You cannot currently detect if this was a 'Not Found' 
        // error to trigger a redirect to /signup.
    }
}

Requirements:

  • Create a UserError enum using thiserror with variants for NotFound and ConnectionError.
  • Update load_user to return Result<String, UserError>.
  • In main, use a match statement on the result of load_user. If it is UserError::NotFound, print "Redirecting to signup page...". For any other error, print "System error: [error message]".
Rating
0 0

There are no comments for now.

to be the first to leave a comment.