Step 06 of 17

Error handling

Handle failure with Result and the ? operator, design custom error types, and choose between thiserror, anyhow and panics.

18 min 8 sections

Rust has no exceptions. A function that can fail says so in its return type, Result<T, E>, and the caller has to deal with it before it can touch the value. That sounds like Go's if err != nil, and the idea is the same, but Rust adds two things that change how it feels: the ? operator, which propagates errors in one character, and the type system, which won't let you forget an error or read a value that isn't there. This lesson covers Result, ?, writing your own error types, the thiserror and anyhow crates, and when panicking is the right call.

Two kinds of failure

Rust separates failures into two kinds.

Recoverable errors are expected in normal operation: a file doesn't exist, input doesn't parse, a network call times out. They're values of type Result<T, E>, and the caller decides what to do.

Unrecoverable errors are bugs: an index out of bounds, an invariant you were sure of turning out false. They panic: the thread unwinds its stack (dropping everything on the way), prints a message, and stops. In a single-threaded program, that ends the process.

The question to ask is "could a correct program hit this?" If yes, return a Result. If it only happens when the code itself is wrong, panicking is fine.

Result

Result is an ordinary enum:

rust
enum Result<T, E> {
    Ok(T),
    Err(E),
}

You handle it like any enum:

rust
use std::fs;

fn main() {
    match fs::read_to_string("config.toml") {
        Ok(text) => println!("{} bytes", text.len()),
        Err(e) => eprintln!("can't read config: {e}"),
    }

    // Or parse with a fallback:
    let port: u16 = "80a".parse().unwrap_or(8080);
    println!("{port}");
}

Result is marked #[must_use], so ignoring one produces a warning. It shares most of Option's combinators (map, map_err, and_then, unwrap_or_else, ok()), and you convert between them with option.ok_or(err) and result.ok().

The ? operator

Most of the time a function can't handle an error itself, so it passes it up. Written out, that's a match per call:

rust
use std::fs;
use std::io;

fn read_username_verbose() -> Result<String, io::Error> {
    let text = match fs::read_to_string("username.txt") {
        Ok(t) => t,
        Err(e) => return Err(e),
    };
    Ok(text.trim().to_string())
}

The ? operator does exactly this: on Ok(v) it evaluates to v; on Err(e) it returns early from the function with the error.

rust
use std::fs;
use std::io;

fn read_username() -> Result<String, io::Error> {
    let text = fs::read_to_string("username.txt")?;
    Ok(text.trim().to_string())
}

? also converts the error on the way out: it calls From::from(e) to turn the error into the function's error type. That's what makes it work across libraries with different error types, as you'll see below. It works on Option too, returning None early, in functions that return Option.

main can return a Result. If it returns Err, the program prints the error's Debug form and exits with a non-zero status:

rust
use std::error::Error;

fn main() -> Result<(), Box<dyn Error>> {
    let port: u16 = std::env::var("PORT")?.parse()?;
    println!("port {port}");
    Ok(())
}

Box<dyn Error> means "any error type". Both std::env::VarError and ParseIntError implement Error, and ? boxes either one. It's convenient for small programs and tests, though callers can't easily tell which error happened.

Custom error types

A library's callers often need to react differently to different failures: retry a timeout, return 404 for a missing record, show a validation message. That calls for an error enum:

rust
use std::fmt;
use std::num::ParseIntError;

#[derive(Debug)]
pub enum ConfigError {
    Missing(String),
    InvalidPort(ParseIntError),
    OutOfRange(u32),
}

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ConfigError::Missing(key) => write!(f, "missing setting {key}"),
            ConfigError::InvalidPort(_) => write!(f, "port is not a number"),
            ConfigError::OutOfRange(p) => write!(f, "port {p} is out of range"),
        }
    }
}

impl std::error::Error for ConfigError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            ConfigError::InvalidPort(e) => Some(e),
            _ => None,
        }
    }
}

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

pub fn parse_port(raw: Option<&str>) -> Result<u16, ConfigError> {
    let raw = raw.ok_or_else(|| ConfigError::Missing("PORT".into()))?;
    let n: u32 = raw.trim().parse()?; // ParseIntError -> ConfigError via From
    u16::try_from(n).map_err(|_| ConfigError::OutOfRange(n))
}

Three pieces make a good error type:

  • Display gives a human-readable, lowercase message without trailing punctuation, so errors chain cleanly in logs.
  • std::error::Error, with source() returning the underlying cause, lets tools walk the chain, like Go's errors.Unwrap.
  • From implementations let ? convert lower-level errors automatically.

Callers can now branch on the variant with match, which is exhaustive, so a new variant forces every caller to decide what to do. Compare that with Go's errors.Is and errors.As, where nothing tells you a new sentinel exists.

thiserror and anyhow

That's a lot of boilerplate, so almost every Rust project uses one or both of two crates (both by the same author, dtolnay).

thiserror derives Display, Error and From for your error types. Use it in libraries, and in the parts of an application whose callers need to match on errors:

rust
use thiserror::Error;

#[derive(Debug, Error)]
pub enum ConfigError {
    #[error("missing setting {0}")]
    Missing(String),
    #[error("port is not a number")]
    InvalidPort(#[from] std::num::ParseIntError),
    #[error("port {0} is out of range")]
    OutOfRange(u32),
}

That's equivalent to the hand-written version above. #[from] generates the From impl and marks the field as the source.

anyhow gives you one error type, anyhow::Error, that can hold any error plus a chain of context messages. Use it in applications — binaries, CLI tools, the top of a service — where errors are mostly logged or shown, not matched on:

rust
use anyhow::{Context, Result, bail};

fn load_port(path: &str) -> Result<u16> {
    let text = std::fs::read_to_string(path)
        .with_context(|| format!("reading {path}"))?;
    let port: u16 = text.trim().parse().context("parsing port")?;
    if port < 1024 {
        bail!("port {port} needs root; pick one above 1023");
    }
    Ok(port)
}

fn main() -> Result<()> {
    let port = load_port("port.txt")?;
    println!("{port}");
    Ok(())
}
text
Error: reading port.txt

Caused by:
    No such file or directory (os error 2)

context and with_context play the role of Go's fmt.Errorf("doing x: %w", err): each layer adds what it was trying to do. bail! returns early with a formatted error. You can still recover a specific type with err.downcast_ref::<ConfigError>().

bash
cargo add thiserror anyhow

When to panic

unwrap() and expect() panic on Err or None. They're appropriate when:

  • The failure is a bug. You just checked the vector isn't empty, so v.first().expect("checked non-empty above") can't fail unless the code is wrong. Write the reason in expect.
  • The value is a constant you control, such as "127.0.0.1:8080".parse::<SocketAddr>().unwrap() or a hard-coded regex.
  • Startup configuration is broken and the program can't do anything useful. Crashing early with a clear message beats limping along.
  • Tests and examples, where a panic is a test failure with a stack trace.

They're not appropriate for anything a user, the network, the filesystem or another service can cause. A web server that panics on a malformed request body has a denial-of-service bug.

Panics in a spawned thread or async task stop only that thread or task; the runtime reports it to whoever joins it. Don't use panics for control flow: catch_unwind exists for FFI boundaries and test harnesses, not as try/catch.

Practice

Build a small, testable config loader.

  1. Create a project and cargo add thiserror anyhow.
  2. Define enum SettingsError with thiserror: Missing(&'static str), BadNumber { key: &'static str, source: std::num::ParseIntError } and Invalid(String).
  3. Write fn parse_settings(input: &str) -> Result<Settings, SettingsError> where input looks like "workers=4\ntimeout_ms=1500". Settings has workers: u8 (1–64) and timeout_ms: u64. Both keys are required.
  4. In main, use anyhow to read the input from a file given on the command line, add context, and print the parsed settings.
  5. Try it with a missing file, a missing key, a non-number and workers=0. Check each message reads well.
Show solution
rust
use anyhow::Context;
use thiserror::Error;

#[derive(Debug)]
struct Settings {
    workers: u8,
    timeout_ms: u64,
}

#[derive(Debug, Error)]
enum SettingsError {
    #[error("missing required setting {0}")]
    Missing(&'static str),
    #[error("setting {key} is not a valid number")]
    BadNumber { key: &'static str, source: std::num::ParseIntError },
    #[error("invalid setting: {0}")]
    Invalid(String),
}

fn lookup<'a>(input: &'a str, key: &'static str) -> Result<&'a str, SettingsError> {
    input
        .lines()
        .filter_map(|line| line.split_once('='))
        .find(|(k, _)| k.trim() == key)
        .map(|(_, v)| v.trim())
        .ok_or(SettingsError::Missing(key))
}

fn parse_settings(input: &str) -> Result<Settings, SettingsError> {
    let workers: u8 = lookup(input, "workers")?
        .parse()
        .map_err(|source| SettingsError::BadNumber { key: "workers", source })?;
    if !(1..=64).contains(&workers) {
        return Err(SettingsError::Invalid(format!("workers must be 1-64, got {workers}")));
    }
    let timeout_ms = lookup(input, "timeout_ms")?
        .parse()
        .map_err(|source| SettingsError::BadNumber { key: "timeout_ms", source })?;
    Ok(Settings { workers, timeout_ms })
}

fn main() -> anyhow::Result<()> {
    let path = std::env::args().nth(1).context("usage: settings <file>")?;
    let text = std::fs::read_to_string(&path).with_context(|| format!("reading {path}"))?;
    let settings = parse_settings(&text).with_context(|| format!("parsing {path}"))?;
    println!("{settings:?}");
    Ok(())
}

A field named source is automatically treated as the error's source by thiserror, so BadNumber chains to the ParseIntError without a #[from] (which wouldn't work here anyway, since the key is extra data). parse_settings returns a typed error, so a test can assert matches!(err, SettingsError::Missing("workers")). main wraps everything with context for a human. The 'a on lookup says the returned &str borrows from input; the lifetimes lesson explains it.

Key takeaways

  • Recoverable failures are Result<T, E> values; bugs panic. Ask whether a correct program could hit the failure.
  • ? returns early on Err (or None) and converts the error with From, replacing a match per call.
  • A good error type implements Display, Error (with source) and From for the errors it wraps.
  • Use thiserror for typed errors that callers match on, and anyhow with .context() in applications.
  • unwrap/expect are for invariants, constants, startup and tests — never for input you don't control.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.What does the ? in let n: u32 = s.parse()?; do when parse fails?

  2. 2.You're writing a library that parses invoices. Callers need to tell a missing field from a malformed date. What should your functions return?

  3. 3.Which is the most reasonable use of expect?

  4. 4.What does anyhow's .context("loading config") add to an error?

Concepts in this lesson

One-page notes on each idea, for review or a quick lookup later.

Finished reading?

Mark it complete to fill in your roadmap.