Skip to content
C Codeloom
Rust

Rust Error Handling with Result

How idiomatic Rust handles errors with Result and the ? operator: propagation, conversion, custom error types, and when to use anyhow or thiserror.

·5 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • Why Rust prefers Result over exceptions
  • How the ? operator propagates errors
  • Error conversion with From and ?
  • When to define custom error types
  • Choosing between anyhow and thiserror

Prerequisites

  • Basic familiarity with the language

Rust does not have exceptions. Recoverable errors are values, returned from functions like any other data. That sounds heavy at first, but the language adds enough sugar that idiomatic Rust error handling is often cleaner than try/catch. The price is having to think about which errors you want to surface and how they should compose.

Result and Option

The two error-shaped types in the standard library are:

enum Option<T> { Some(T), None }
enum Result<T, E> { Ok(T), Err(E) }

Option is for “value or nothing.” Result is for “value or error.” Either can be pattern-matched directly.

fn parse_age(s: &str) -> Result<u32, std::num::ParseIntError> {
    s.parse::<u32>()
}

match parse_age("42") {
    Ok(age) => println!("age is {}", age),
    Err(e)  => eprintln!("not a number: {}", e),
}

Pattern matching every call gets old fast. That is where the question-mark operator comes in.

The question-mark operator

? after a Result does one of two things. If the value is Ok, it unwraps it. If it is Err, it returns from the current function with that error.

fn read_config(path: &str) -> Result<Config, std::io::Error> {
    let text = std::fs::read_to_string(path)?;
    let parsed = Config::from_str(&text)?;
    Ok(parsed)
}

Two ? operators replace a half-dozen lines of match. Each ? is a possible early return.

? also works on Option<T>, returning None early if the value is None.

The mental model

A function that returns Result<T, E> is honest about failure. Its signature tells you exactly what can go wrong.

fn outer() -> Result<T, E> {
  let a = step1()?;   // Err -> early return as Err
  let b = step2(a)?;  // Err -> early return as Err
  Ok(transform(b))    // success path
}
Error propagation with ?

Each ? is a junction: the value either continues forward or shoots back up to the caller. The shape of the function is the success path; failure is a side channel handled by the language.

Error conversion

The previous example assumes every step returns the same error type. In real code, different layers produce different errors. The ? operator converts errors via the From trait.

#[derive(Debug)]
enum AppError {
    Io(std::io::Error),
    Parse(std::num::ParseIntError),
}

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

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

fn run() -> Result<u32, AppError> {
    let text = std::fs::read_to_string("count.txt")?; // io::Error -> AppError
    let n: u32 = text.trim().parse()?;                 // ParseIntError -> AppError
    Ok(n * 2)
}

? calls .into() on the error, which uses the From impl. Once you have those impls in place, mixing errors in one function is painless.

thiserror

Writing those From impls by hand is straightforward but boilerplatey. The thiserror crate generates them via a derive macro.

use thiserror::Error;

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

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

The #[from] attribute generates the From impl. The #[error("...")] strings provide Display. thiserror is the right tool for library code where the error type is part of your public API.

anyhow

For application code where you mostly want to bubble errors up to main and print them, the anyhow crate offers a single concrete error type that wraps anything.

use anyhow::{Context, Result};

fn run() -> Result<u32> {
    let text = std::fs::read_to_string("count.txt")
        .context("reading count.txt")?;
    let n: u32 = text.trim().parse()
        .context("parsing count as u32")?;
    Ok(n * 2)
}

anyhow::Result<T> is Result<T, anyhow::Error>. The .context(...) adds a layer of message for debugging. Application code rarely needs more.

The rule of thumb: applications use anyhow, libraries use thiserror, and one application can use both (define structured error types where they matter, fall back to anyhow elsewhere).

panic vs Result

panic! is for unrecoverable bugs: invariants violated, indices out of range, things that should never happen in correct code. Result is for expected failure modes: I/O, parsing, validation.

fn divide(a: i32, b: i32) -> i32 {
    assert!(b != 0, "division by zero is a caller bug");
    a / b
}

Use assert!, expect, and unwrap when failure means the program is broken, not when input is bad. In server code, panics typically tear down the current request or task; in CLI code, they exit the program.

Common pitfalls

Calling .unwrap() everywhere “for now” and shipping it. Pick expect("descriptive message") at minimum so the panic message tells future-you what went wrong.

Defining one massive AppError enum used by everything. It works but becomes a god type. Smaller, layered error types compose better.

Wrapping Result<T, E> in another Result. If a function returns Result<Result<T, E1>, E2>, you have probably forgotten a ? or a conversion.

Discarding errors with let _ = something();. Sometimes correct, often a bug in disguise. At least log it.

Practical tips

Write the success path. Sprinkle ? until each step type-checks. Let conversions happen via From. Only reach for explicit match when you need to handle particular variants differently.

For libraries, expose specific error types. Consumers of your library benefit from being able to match on what failed.

For binaries, use anyhow::Result in main and add context liberally. The resulting backtrace-like error chain is one of the best debugging affordances in Rust.

Wrap-up

Result-based error handling is one of the parts of Rust that feels unusual at first and obvious in retrospect. Errors as values, ? for propagation, From for conversion, and the thiserror/anyhow pair for ergonomics together give you a system that is both type-safe and pleasant. Once you stop reaching for exceptions, the discipline of saying exactly what can fail becomes its own reward.