Skip to Content
Course content

160: Application-Level Errors with anyhow

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 Rust's error system. If you've been following the library patterns we discussed previously, you're used to defining exhaustive enums with thiserror to make sure every possible failure is typed and handled. That's great for a library where the caller needs to know exactly what went wrong to decide how to react. But when you're writing the actual application—the CLI tool, the server, the "main" logic—that level of granularity often becomes a burden.

The struggle of the "Everything Enum"

Imagine you're building a small utility that reads a JSON configuration file and then fetches some data from an API. In a "strictly typed" world, your error handling might look like this:

enum AppError {
    Io(std::io::Error),
    Parse(serde_json::Error),
    Http(reqwest::Error),
    ConfigMissing(String),
}

impl From<std::io::Error> for AppError { ... }
// ... and so on for every single possible error type

I've seen a lot of developers fall into this trap early on. You spend more time writing From implementations and expanding your enum than you do writing actual business logic. It's exhausting. You're basically building a giant funnel just to eventually print the error to the console and exit the program. If you try to cheat by using Box<dyn std::error::Error>, you lose the ability to easily add context to those errors, leaving your users with a cryptic "No such file or directory" without knowing which file was missing.

Adding context without the boilerplate

This is where anyhow comes in. I think of anyhow::Result as a "catch-all" for application-level errors. It doesn't force you to define a new type for every failure; instead, it provides a type-erased wrapper that can hold any error that implements std::error::Error.

The real magic, however, isn't just the lack of boilerplate—it's the context method. Look at how this changes the way we write the same config-loader logic:

use anyhow::{Context, Result};

fn load_config() -> Result<Config> {
    let content = std::fs::read_to_string("config.json")
        .context("failed to read the configuration file")?;
    
    let config = serde_json::from_str(&content)
        .context("configuration file was not valid JSON")?;
    
    Ok(config)
}

Notice what happened there. We aren't defining a custom enum. We're just using Result<T> (which is an alias for Result<T, anyhow::Error>). If the file is missing, anyhow doesn't just say "No such file"; it wraps that error and tells the user "failed to read the configuration file: No such file or directory." I love this because it gives me a stack-trace-like experience for logical errors without the performance hit or complexity of a full stack trace.

When to put the anyhow hammer down

You might be wondering if this is "cheating" or if it breaks the type safety Rust is famous for. In a way, it is cheating—you're erasing the specific type of the error. But here's the rule of thumb I use: if you are writing a library that other people will depend on, never use anyhow. Your users need to be able to match on your error types to handle them programmatically.

But if you're writing the "top level" of your application, anyhow is almost always the right choice. At the application level, you usually don't care if an error was a std::io::Error or a serde_json::Error in terms of how you recover—usually, the "recovery" is just logging the error and returning a non-zero exit code. Why fight the compiler to maintain a type distinction that you're never actually going to use?




📋 Practical Task

Build a Robust File-to-API Pipeline

Your task is to refactor a fragile piece of code into a professional application-level pipeline using anyhow.

Create a small program that does the following:

  1. Reads a filename from a command-line argument.
  2. Reads the content of that file into a string.
  3. Attempts to parse that string as an integer.
  4. Simulates an API call by dividing 100 by that integer (handling the potential division by zero).

Requirements:

  • Use anyhow::Result as the return type for your functions.
  • Use .context() or .with_context() at every stage of the pipeline (e.g., "Failed to read file X", "File content was not a valid number").
  • In main, handle the result and print the error using {:?} to show the full chain of context.
  • Ensure that if the user provides no command-line argument, the program returns a helpful anyhow error instead of panicking.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.