Polyglot
Languages Rust error handling
Rust § error-handling

Error handling

Rust admits two principal error mechanisms: recoverable errors via Result<T, E>, and unrecoverable errors via panic!. The design is pointed: errors a caller could reasonably handle are returned via Result; conditions that indicate a programmer bug or genuinely unrecoverable state cause a panic. The ? operator admits compact error propagation through Result chains. The conventional ecosystem uses thiserror for library-defined error types and anyhow for application error handling. The combination — Result for propagation, ? for chains, panic! for bugs, Option for absent-value cases — covers the error-handling surface; the type system ensures errors are explicit and cannot be silently ignored.

Result<T, E>

The standard library’s principal error type:

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

The conventional uses:

use std::num::ParseIntError;

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

let r1 = parse("42");                            // Ok(42)
let r2 = parse("not a number");                  // Err(ParseIntError { ... })

match r1 {
    Ok(n) => println!("got {}", n),
    Err(e) => println!("error: {}", e),
}

A function that may fail returns Result<T, E>; the caller must explicitly handle both cases.

The ? operator

The ? operator admits compact error propagation:

use std::num::ParseIntError;

fn parse_two(s1: &str, s2: &str) -> Result<i32, ParseIntError> {
    let a: i32 = s1.parse()?;
    let b: i32 = s2.parse()?;
    Ok(a + b)
}

Each ?:

  • If the value is Ok(x), unwraps it as x.
  • If the value is Err(e), returns Err(e) from the enclosing function.

The ? is roughly:

let a: i32 = match s1.parse() {
    Ok(n) => n,
    Err(e) => return Err(e),
};

The conventional Rust error-handling style is built around chains of ?:

fn read_and_parse(path: &str) -> Result<i32, Box<dyn std::error::Error>> {
    let contents = std::fs::read_to_string(path)?;
    let n: i32 = contents.trim().parse()?;
    Ok(n * 2)
}

Error type conversion

The ? operator applies the From trait to convert error types:

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

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

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

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

fn read_and_parse(path: &str) -> Result<i32, MyError> {
    let contents = std::fs::read_to_string(path)?;     // io::Error → MyError
    let n: i32 = contents.trim().parse()?;             // ParseIntError → MyError
    Ok(n)
}

The ? calls From::from() to convert; if a conversion is not implemented, the compiler reports a type error.

Box<dyn Error>

For applications without elaborate error types, Box<dyn std::error::Error> admits propagating any error:

fn read_and_parse(path: &str) -> Result<i32, Box<dyn std::error::Error>> {
    let contents = std::fs::read_to_string(path)?;
    let n: i32 = contents.trim().parse()?;
    Ok(n)
}

The Box<dyn Error> is a trait object admitting any type implementing the Error trait. The form admits substantial conciseness in application code; libraries conventionally define explicit error types.

Defining error types

For library-quality error types, the conventional pattern:

use std::fmt;

#[derive(Debug)]
enum ParseError {
    Empty,
    Invalid(String),
    OutOfRange { value: i64, max: i64 },
}

impl fmt::Display for ParseError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            ParseError::Empty => write!(f, "empty input"),
            ParseError::Invalid(s) => write!(f, "invalid input: {}", s),
            ParseError::OutOfRange { value, max } => {
                write!(f, "value {} exceeds max {}", value, max)
            }
        }
    }
}

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

The conventional requirements for an error type:

  • Debug — for {:?} formatting (typically derived).
  • Display — for human-readable messages.
  • Error — for the standard error trait (admits ? and trait objects).
  • Optionally Send + Sync + 'static — for thread-safe propagation.

thiserror

The thiserror crate (de facto standard) admits substantial conciseness:

use thiserror::Error;

#[derive(Error, Debug)]
enum DataError {
    #[error("missing field: {0}")]
    MissingField(String),

    #[error("invalid number: {value}")]
    InvalidNumber { value: String },

    #[error("I/O error")]
    Io(#[from] std::io::Error),

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

The #[derive(Error)] and #[error("...")] produce the Display implementation; the #[from] produces From implementations admitting ?-based conversion. The form is the conventional Rust library error definition.

anyhow

The anyhow crate admits substantial conciseness for application code:

use anyhow::{Result, Context};

fn read_config(path: &str) -> Result<Config> {
    let contents = std::fs::read_to_string(path)
        .context(format!("failed to read {}", path))?;
    let config: Config = toml::from_str(&contents)
        .context("failed to parse config")?;
    Ok(config)
}

The anyhow::Result<T> is Result<T, anyhow::Error>; the Error is a flexible trait-object-style error. The .context() admits attaching descriptive messages. The form is the conventional Rust application error handling.

The conventional discipline:

  • Libraries use thiserror — explicit, well-defined error types.
  • Applications use anyhow — flexible, message-focused.

Option<T>

For the “absent value” case, Option<T> rather than Result:

enum Option<T> {
    Some(T),
    None,
}

The conventional uses:

fn first_word(s: &str) -> Option<&str> {
    s.split_whitespace().next()
}

let s = "hello world";
match first_word(s) {
    Some(w) => println!("first word: {}", w),
    None => println!("empty"),
}

// Or with ?:
fn process(s: &str) -> Option<i32> {
    let first = s.split_whitespace().next()?;
    first.parse().ok()
}

The ? works for Option: Some(x) becomes x; None returns None from the enclosing function.

The conventional choice:

  • Option<T> — for “value may be absent” with no error reason needed.
  • Result<T, E> — for “value may be absent because of an error”.

Result and Option methods

Both types have a substantial method surface for combinators:

Unwrapping (panicking)

let v = result.unwrap();                         // panics on Err
let v = result.expect("expected a value");       // panics with custom message
let v = result.unwrap_or(default);               // returns default on Err
let v = result.unwrap_or_else(|e| compute(e));   // computed default
let v = result.unwrap_or_default();              // T::default()

The conventional discipline:

  • unwrap() in tests and prototype code.
  • expect() when the error genuinely should not happen and the caller wants a clear panic message.
  • unwrap_or() / unwrap_or_else() / unwrap_or_default() for fallback.
  • ? in production code for propagation.

Mapping

let r: Result<i32, _> = "42".parse();
let r: Result<i32, _> = r.map(|n| n * 2);        // doubles the Ok value
let r: Result<i32, _> = r.map_err(|e| format!("{}", e));  // transforms the error

Combinators

result.and_then(|x| compute(x));                 // chain; "flatMap"
result.or_else(|e| recover(e));                  // recover from Err
result.is_ok();
result.is_err();
result.ok();                                     // Result<T, E> → Option<T>
result.err();                                    // Result<T, E> → Option<E>

Option methods

let opt: Option<i32> = Some(5);
let opt: Option<i32> = opt.map(|n| n * 2);
let opt: Option<i32> = opt.and_then(|n| if n > 0 { Some(n) } else { None });
let opt: Option<i32> = opt.or(Some(default));
let opt: Option<i32> = opt.or_else(|| compute_default());

let v = opt.unwrap_or(0);
let v = opt.unwrap_or_else(|| compute_default());

let r: Result<i32, &str> = opt.ok_or("missing");
let r: Result<i32, &str> = opt.ok_or_else(|| compute_error());

panic!

For unrecoverable errors:

panic!("unreachable code reached");
panic!("invalid state: {:?}", state);

if condition {
    panic!("assertion failed");
}

assert!(x > 0, "x must be positive, got {}", x);
assert_eq!(actual, expected);

The conventional uses:

  • Programmer bugs — invariants violated.
  • Genuinely unrecoverable conditions — the program cannot continue safely.
  • Testsassert!, assert_eq! panic on failure.
  • Prototypesunwrap(), expect() for code that’s not yet polished.

The panic! unwinds the stack by default, calling destructors as it goes. The behaviour can be configured to abort (immediate termination) for smaller binary size:

[profile.release]
panic = "abort"

Recovering from panics

std::panic::catch_unwind admits catching a panic:

use std::panic;

let result = panic::catch_unwind(|| {
    do_something_risky();
});

match result {
    Ok(v) => println!("succeeded: {:?}", v),
    Err(_) => println!("panicked"),
}

The mechanism is rare in idiomatic code; conventionally, panic indicates a bug, and the program should terminate. The principal use is for FFI boundaries (preventing panics from unwinding into C code) and test runners.

Common patterns

Validate-and-parse

fn parse_positive(s: &str) -> Result<u32, ParseError> {
    let n: i32 = s.parse().map_err(|_| ParseError::Invalid(s.to_string()))?;
    if n < 0 {
        Err(ParseError::Negative(n))
    } else {
        Ok(n as u32)
    }
}

Default on error

let port: u16 = std::env::var("PORT")
    .ok()
    .and_then(|s| s.parse().ok())
    .unwrap_or(8080);

Error context

use anyhow::{Context, Result};

fn read_config(path: &str) -> Result<Config> {
    let contents = std::fs::read_to_string(path)
        .with_context(|| format!("failed to read config from {}", path))?;
    let config: Config = toml::from_str(&contents)
        .with_context(|| format!("failed to parse config at {}", path))?;
    Ok(config)
}

The error message accumulates context, producing a chain like:

Error: failed to parse config at /etc/app.toml

Caused by:
    expected `=`, found `:` at line 5

Library error types with thiserror

use thiserror::Error;

#[derive(Error, Debug)]
pub enum DatabaseError {
    #[error("connection failed: {0}")]
    Connection(String),

    #[error("query failed")]
    Query(#[from] QueryError),

    #[error("transaction error: {message}")]
    Transaction { message: String },
}

Application errors with anyhow

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

fn process(input: &str) -> Result<i32> {
    if input.is_empty() {
        bail!("input is empty");
    }
    if input.len() > 100 {
        return Err(anyhow!("input too long: {}", input.len()));
    }

    let n: i32 = input.parse()?;
    Ok(n * 2)
}

Early return on error

fn process(input: &str) -> Result<i32, String> {
    let n: i32 = match input.parse() {
        Ok(n) => n,
        Err(_) => return Err("not a number".to_string()),
    };

    if n < 0 {
        return Err("negative".to_string());
    }

    Ok(n * 2)
}

// More conventionally:
fn process(input: &str) -> Result<i32, String> {
    let n: i32 = input.parse().map_err(|_| "not a number".to_string())?;
    if n < 0 {
        return Err("negative".to_string());
    }
    Ok(n * 2)
}

Result-type conversion

let r: Result<i32, String> = parse_number(input);

// As Option:
let opt: Option<i32> = r.ok();

// Convert error:
let r: Result<i32, MyError> = r.map_err(MyError::ParseFailure);

// Default on error:
let v: i32 = r.unwrap_or(0);

A note on assert! vs Result

The conventional choice:

  • assert! — for invariants that must hold; failure indicates a bug.
  • Result — for operations that may fail under reasonable conditions.
fn divide(a: i32, b: i32) -> Result<i32, &'static str> {
    if b == 0 {
        return Err("division by zero");
    }
    Ok(a / b)
}

fn binary_search<T: Ord>(slice: &[T], target: &T) -> Option<usize> {
    assert!(slice.windows(2).all(|w| w[0] <= w[1]),
            "slice must be sorted");
    // ... actual search ...
}

The assert! admits expressing preconditions; the Result admits expressing fallibility.

A note on ? in main

main may return Result:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let contents = std::fs::read_to_string("file.txt")?;
    println!("{}", contents);
    Ok(())
}

The ? works in main if the return type is compatible. The form admits substantial conciseness for prototype code and command-line tools.

A note on the conventional discipline

The contemporary Rust error-handling advice:

  • Use Result<T, E> for fallible operations.
  • Use ? for propagation; avoid manual match chains.
  • Use thiserror for library error types.
  • Use anyhow for application error handling.
  • Use panic! only for genuine bugs and unrecoverable conditions.
  • Use assert! and debug_assert! for invariants.
  • Provide context with .context() (anyhow) or map_err.
  • Use Option<T> for absent-value cases without an error reason.
  • Implement Display on error types — produces human-readable messages.
  • Implement From on error types — admits the ? operator across error types.

The combination — Result for explicit error propagation, ? for compact chains, panic! for bugs, the Error trait, the thiserror/anyhow ecosystem — is the substance of Rust’s error-handling discipline. The mechanism admits substantial robustness without exception-style overhead.