Updated Jul 30, 2026

11. Advanced Error Handling

📋 Jump to Takeaways

🎁 You know how to return Result and use ? for a single error type. But what happens when one function can fail in two completely different ways, and callers need to know which one happened?

Multiple Error Types

What happens when one function can fail in two different ways? You can't return Result<T, io::Error> when another call produces a parse error. Box<dyn Error> is the catch-all that accepts either one.

use std::error::Error;
use std::fs;

fn parse_port() -> Result<u16, Box<dyn Error>> {
    let contents = fs::read_to_string("port.txt")?;  // io::Error
    let port = contents.trim().parse::<u16>()?;       // ParseIntError
    Ok(port)                                           // success: u16 wrapped in Ok
}

Let's break down Box<dyn Error>:

  • Error — a trait (like a Go interface). Any type that implements it qualifies.
  • dyn — "dynamic dispatch." The concrete type is resolved at runtime, not compile time. Without dyn, Rust needs to know the exact type and its size at compile time.
  • Box<T> — a smart pointer that allocates on the heap. Different error types have different sizes, and Rust needs fixed-size return types. Box gives you a fixed-size pointer to any error on the heap.
  • Ok(port) wraps the parsed u16 in the success variant of Result<u16, Box<dyn Error>>. It's the function's return value on the happy path, the same as return port, nil in Go.

So Box<dyn Error> means "a heap-allocated value of any type that implements the Error trait." It's Rust's equivalent of Go's error interface — a single return type that accepts any error.

You lose specific type info, but gain flexibility. This is fine for application code.

Custom Error Types

Box<dyn Error> is convenient but you lose type information — callers can't match on which error happened. For libraries or services where callers need to handle different failures differently, define your own error enum.

The idea: one enum with a variant for each kind of failure your function can produce. This is similar to how you'd define a custom error type in Go with a type switch, but the compiler enforces exhaustive handling.

Before we define it, let's clarify the types we'll use:

  • io::Error — a struct from std::io. It's what file and network operations return when they fail (file not found, permission denied, connection refused). Think of it like Go's *os.PathError.
  • ParseIntError — a struct from std::num. It's what .parse::<u16>() returns when the string isn't a valid number. Like Go's strconv.ErrSyntax.
  • String — you already know this one. We'll use it for custom messages.

Each of these becomes a variant in our error enum:

use std::fmt;
use std::num::ParseIntError;
use std::io;

#[derive(Debug)]
enum AppError {
    Io(io::Error),          // wraps a file/network error
    Parse(ParseIntError),   // wraps a number parsing error
    Invalid(String),        // your own custom variant (validation failure)
}

To make this work as an error type, you need three things:

1. Implement Display so the error can be printed with {}:

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            AppError::Io(e) => write!(f, "IO error: {}", e),
            AppError::Parse(e) => write!(f, "Parse error: {}", e),
            AppError::Invalid(msg) => write!(f, "Invalid: {}", msg),
        }
    }
}

2. Implement the Error trait (it requires Display + Debug, which we already have):

impl std::error::Error for AppError {}

3. Implement From for each error type you want to convert automatically. This is what makes ? work — when ? encounters an io::Error, it calls From to convert it into your AppError:

impl From<io::Error> for AppError {
    fn from(e: io::Error) -> Self {
        AppError::Io(e)
    }
}

impl From<ParseIntError> for AppError {
    fn from(e: ParseIntError) -> Self {
        AppError::Parse(e)
    }
}

Now ? automatically converts errors into your custom type:

fn load_config() -> Result<u16, AppError> {
    let contents = fs::read_to_string("port.txt")?;  // io::Error → AppError::Io
    let port = contents.trim().parse::<u16>()?;       // ParseIntError → AppError::Parse
    Ok(port)
}

And callers can match on specific failures:

match load_config() {
    Ok(port) => println!("Port: {}", port),
    Err(AppError::Invalid(msg)) => println!("Validation: {}", msg),
    Err(AppError::Io(e)) => println!("File error: {}", e),
    Err(AppError::Parse(e)) => println!("Bad format: {}", e),
}

Error Propagation Chain

Here's how errors flow through a real application:

fn read_port() -> Result<u16, AppError> {
    let text = fs::read_to_string("port.txt")?;
    let port = text.trim().parse::<u16>()?;
    if port < 1024 {
        return Err(AppError::Invalid("port must be >= 1024".into()));
    }
    Ok(port)
}

fn start_server() -> Result<(), AppError> {
    let port = read_port()?;
    println!("Starting on port {}", port);
    Ok(())
}

fn main() {
    match start_server() {
        Ok(()) => println!("Server running"),
        Err(e) => eprintln!("Failed to start: {}", e),
    }
}
// If port.txt is missing:   "Failed to start: IO error: No such file or directory"
// If port.txt has "abc":    "Failed to start: Parse error: invalid digit found in string"
// If port.txt has "80":     "Failed to start: Invalid: port must be >= 1024"

Each layer adds context. The top-level main makes one decision: print success or print the error.

Reminder: Ok and Err are just the two variants of Result<T, E>, no import needed. Ok(value) carries the success case, Err(error) carries the failure case. Every function above returns one or the other.

anyhow and thiserror

Writing all those From impls by hand gets tedious. The ecosystem has two crates that handle it:

thiserror, for libraries. Generates Display and From impls via derive macros. Add it first:

cargo add thiserror

That adds it to Cargo.toml so the derive macros below are available:

use thiserror::Error;

#[derive(Debug, Error)]
enum AppError {
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),

    #[error("Parse error: {0}")]
    Parse(#[from] std::num::ParseIntError),

    #[error("Invalid: {0}")]
    Invalid(String),
}

That's it. All the boilerplate from before, gone.

anyhow, for applications. When you don't need callers to match on specific error types. Add it the same way:

cargo add anyhow

With that in place, .context() and anyhow::Result are ready to use:

use anyhow::{Context, Result};

fn read_port() -> Result<u16> {
    let text = std::fs::read_to_string("port.txt")
        .context("failed to read port.txt")?;
    let port = text.trim().parse::<u16>()
        .context("port.txt must contain a valid port number")?;
    Ok(port)
}

fn main() -> Result<()> {
    let port = read_port()?;
    println!("Starting on port {}", port);
    Ok(())
}
// If port.txt is missing:
// Error: failed to read port.txt
// Caused by: No such file or directory

anyhow::Result<T> is shorthand for Result<T, anyhow::Error>. The .context() method adds human-readable messages to the error chain. Use thiserror when you're writing a library others consume. Use anyhow when you're writing an application and just need good error messages.

Key Takeaways

  • Box<dyn Error> accepts any error type — quick and flexible for application code
  • Custom error enums give callers precise control over which failure happened
  • Implementing Display, Error, and From makes your custom type work with ?
  • thiserror generates all the boilerplate with derive macros (use for libraries)
  • anyhow simplifies error handling with .context() (use for applications)
  • Error propagation with ? and From lets errors flow up the call stack cleanly

🎁 What happens when you return a reference from a function? How does the compiler know the reference is still valid? Next up: lifetimes give the compiler proof that a reference won't outlive the data it points to.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue
© 2026 ByteLearn.dev. Free courses for developers. · Privacy