Skip to content
C Codeloom
Rust

Rust Error Handling with Result and the ? Operator

A practical guide to error handling in Rust covering Result, the ? operator, unwrap and expect, custom error types, and the thiserror and anyhow crates.

·5 min read · By Yash Kesharwani
Intermediate 10 min read

What you'll learn

  • Return Result<T, E> from fallible functions
  • Propagate errors concisely with the ? operator
  • Know when unwrap and expect are appropriate
  • Design custom error enums for your library
  • Pick between thiserror and anyhow for the job

Prerequisites

  • A Rust environment — see /blog/rust-install-and-first-program
  • Familiarity with types — see /blog/rust-variables-and-types
  • Comfort writing functions — see /blog/rust-functions

Rust does not have exceptions. Recoverable failures are values; unrecoverable bugs panic. That single design choice shapes every API in the language. Once you internalize Result<T, E> and the ? operator, error handling stops feeling like ceremony and starts feeling like a normal part of writing code.

Result<T, E> in Practice

Result<T, E> is an enum with two variants: Ok(T) for success and Err(E) for failure.

use std::num::ParseIntError;

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

match parse_age("42") {
    Ok(age) => println!("age is {}", age),
    Err(e) => println!("bad input: {}", e),
}

Every fallible standard-library function returns a Result. File IO, network calls, parsing, and conversions all surface their failures through this enum rather than throwing.

The ? Operator

Manually matching every call is noisy. The ? operator is shorthand for “if this is Err, return it from the current function; otherwise unwrap the Ok value.”

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

fn read_config() -> Result<String, io::Error> {
    let raw = fs::read_to_string("config.toml")?;
    Ok(raw)
}

You can chain several fallible calls in a single expression and let ? shortcut the whole chain on the first error.

fn pipeline() -> Result<u32, Box<dyn std::error::Error>> {
    let raw = fs::read_to_string("count.txt")?;
    let n: u32 = raw.trim().parse()?;
    Ok(n * 2)
}

The two ?s above return different error types (io::Error and ParseIntError). They both compile because Box<dyn std::error::Error> accepts any error via the From trait. We will tighten this up shortly with a real error type.

When to unwrap or expect

unwrap and expect extract the Ok value and panic on Err. They are appropriate in tests, in examples, and in places where an Err truly indicates a bug rather than a runtime condition.

let port: u16 = std::env::var("PORT")
    .expect("PORT must be set")
    .parse()
    .expect("PORT must be a u16");

Reach for expect over unwrap whenever you can, because the message turns into the panic text and dramatically eases debugging. In library code, prefer returning Result and let callers decide.

Custom Error Types

For libraries, define an enum that enumerates the distinct ways a function can fail. This gives callers something they can match on.

use std::fmt;

#[derive(Debug)]
pub enum ConfigError {
    Io(std::io::Error),
    Parse(String),
    Missing(&'static str),
}

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ConfigError::Io(e) => write!(f, "io error: {}", e),
            ConfigError::Parse(m) => write!(f, "parse error: {}", m),
            ConfigError::Missing(k) => write!(f, "missing key: {}", k),
        }
    }
}

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

Implement From so ? can convert lower-level errors into your enum automatically.

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

Now this compiles cleanly:

fn load() -> Result<String, ConfigError> {
    let raw = std::fs::read_to_string("config.toml")?; // io::Error -> ConfigError
    if raw.is_empty() {
        return Err(ConfigError::Missing("any content"));
    }
    Ok(raw)
}

thiserror for Libraries

Writing Display, Error, and From impls by hand grows tedious. The thiserror crate is a procedural macro that does it for you.

use thiserror::Error;

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

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

    #[error("missing key: {0}")]
    Missing(&'static str),
}

The #[from] attribute generates the conversion, and #[error("...")] writes Display. The result is a precise, typed error suitable for a public API.

anyhow for Applications

When you are writing a binary and just want to propagate any error with a useful message, the anyhow crate provides a single boxed error type.

use anyhow::{Context, Result};

fn run() -> Result<()> {
    let raw = std::fs::read_to_string("config.toml")
        .context("reading config.toml")?;
    println!("loaded {} bytes", raw.len());
    Ok(())
}

anyhow::Result<T> is Result<T, anyhow::Error>. The Context extension trait adds a chained message to whatever lower-level error popped up, which produces excellent diagnostics:

Error: reading config.toml

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

A common rule of thumb: thiserror in libraries, anyhow in applications. Libraries should expose typed errors so consumers can branch on them. Applications care more about reporting than branching.

Choosing Between Result and Panic

Use a Result when the error is something the caller might want to handle: missing file, bad input, network down. Use a panic! (or unwrap, or assert) when invariants are violated and continuing would be wrong: an array index that “cannot” be out of bounds, a state machine in an impossible state, a programming bug.

A useful test: would you write a test that asserts the error case? If yes, return a Result. If no, panic.

Combining Errors With map_err

Sometimes ? cannot convert because no From impl exists. Translate explicitly with map_err.

fn parse_port(s: &str) -> Result<u16, ConfigError> {
    s.parse::<u16>().map_err(|e| ConfigError::Parse(e.to_string()))
}

Result also has and_then, or_else, and ok for composition, plus ? works inside Option for the same shortcut behavior.

Putting It Together

A small program that reads a port and starts a placeholder service.

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

fn parse_port(s: &str) -> Result<u16> {
    let p: u16 = s.parse().with_context(|| format!("invalid port: {}", s))?;
    if p < 1024 { bail!("port must be >= 1024"); }
    Ok(p)
}

fn main() -> Result<()> {
    let raw = std::env::var("PORT").context("PORT not set")?;
    let port = parse_port(&raw)?;
    println!("starting on :{}", port);
    Ok(())
}

main itself can return Result. On Err, the runtime prints the error chain and exits non-zero.

Wrap up

Rust’s error story is: values not exceptions, Result for recoverable cases, panic for bugs, ? to keep code readable, and thiserror or anyhow to remove boilerplate. Once your APIs adopt these patterns, errors become composable building blocks rather than control-flow surprises.