Skip to main content
Rust intermediate Lesson 16 of 30

Error Handling in Rust

Use Result<T,E>, the ? operator, custom error types, thiserror, and anyhow for robust error handling.

The Two Strategies: panic! and Result

Rust draws a clear line between two kinds of failure. A panic! signals a bug — something that should never happen in correct code, like an out-of-bounds access or a violated invariant. A Result signals an expected failure — something that can go wrong in normal operation, like a file not found or a network timeout. Separating these means callers always know whether a function can fail in a recoverable way, and the compiler enforces that they handle it.

// panic! — unrecoverable, crashes the thread immediately
fn divide_unsafe(a: i32, b: i32) -> i32 {
    if b == 0 { panic!("division by zero!"); }
    a / b
}

// Result — recoverable, caller decides what to do with the error
fn divide(a: i32, b: i32) -> Result<i32, String> {
    if b == 0 { Err(String::from("division by zero")) }
    else { Ok(a / b) }
}

fn main() {
    match divide(10, 2) {
        Ok(n)  => println!("{}", n),           // 5
        Err(e) => eprintln!("Error: {}", e),
    }

    match divide(10, 0) {
        Ok(n)  => println!("{}", n),
        Err(e) => eprintln!("Error: {}", e),   // Error: division by zero
    }
}

The ? Operator

Propagating errors manually with match on every fallible call quickly becomes verbose. The ? operator is syntactic sugar that does the propagation for you: it extracts the Ok value if the result succeeded, and if it failed it converts the error (using the From trait) and returns early. This lets you write a chain of fallible operations as if they were sequential, with errors handled implicitly.

use std::fs;
use std::num::ParseIntError;

fn read_number_from_file(path: &str) -> Result<i32, Box<dyn std::error::Error>> {
    let content = fs::read_to_string(path)?; // propagates io::Error if file missing
    let n: i32 = content.trim().parse()?;    // propagates ParseIntError if not a number
    Ok(n * 2)
}

? expands to approximately:

let content = match fs::read_to_string(path) {
    Ok(v) => v,
    Err(e) => return Err(e.into()), // .into() converts via the From trait
};

? also works with Option — it returns None early instead of Err:

fn first_char(s: &str) -> Option<char> {
    let c = s.chars().next()?;             // returns None if string is empty
    Some(c.to_uppercase().next()?)
}

fn main() {
    println!("{:?}", first_char("hello")); // Some('H')
    println!("{:?}", first_char(""));      // None
}

Handling Result Without ?

When you need fine-grained control over what to do with each outcome, the Result type has a rich set of methods for transforming, chaining, and providing defaults:

fn main() {
    let r: Result<i32, &str> = Ok(42);

    // match — full control over both branches
    match r { Ok(n) => println!("{}", n), Err(e) => println!("{}", e) }

    // unwrap — panics on Err (use only in tests or when Err is truly impossible)
    println!("{}", r.unwrap());

    // expect — panics with a descriptive message (better than unwrap in tests)
    println!("{}", r.expect("should have a number"));

    // unwrap_or — provide a fallback value
    println!("{}", r.unwrap_or(0));

    // unwrap_or_else — compute the fallback lazily
    println!("{}", r.unwrap_or_else(|_| -1));

    // map — transform the Ok value, pass Err through unchanged
    println!("{:?}", r.map(|n| n * 2));    // Ok(84)

    // map_err — transform the Err value, pass Ok through unchanged
    let e: Result<i32, i32> = Err(5);
    println!("{:?}", e.map_err(|n| n * 2)); // Err(10)

    // and_then — chain a second fallible operation
    let result = r.and_then(|n| if n > 0 { Ok(n) } else { Err("negative") });
    println!("{:?}", result); // Ok(42)
}

Defining Custom Error Types

For library code, a string error type like String loses information and makes programmatic error handling impossible for callers. A custom error enum lets callers match on specific variants, access structured data, and understand the error chain. The std::error::Error trait and Display implementation make your error work with the broader ecosystem.

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

#[derive(Debug)]
enum AppError {
    Parse(ParseIntError),
    OutOfRange { value: i32, min: i32, max: i32 },
    NotFound(String),
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            AppError::Parse(e) => write!(f, "parse error: {}", e),
            AppError::OutOfRange { value, min, max } =>
                write!(f, "{} is out of range [{}, {}]", value, min, max),
            AppError::NotFound(key) => write!(f, "'{}' not found", key),
        }
    }
}

impl std::error::Error for AppError {
    // source() lets callers traverse the error chain
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            AppError::Parse(e) => Some(e),
            _ => None,
        }
    }
}

// Allow ? to convert ParseIntError into AppError automatically
impl From<ParseIntError> for AppError {
    fn from(e: ParseIntError) -> Self {
        AppError::Parse(e)
    }
}

fn parse_port(s: &str) -> Result<u16, AppError> {
    let n: i32 = s.parse()?; // ParseIntError → AppError via From impl above
    if n < 1 || n > 65535 {
        return Err(AppError::OutOfRange { value: n, min: 1, max: 65535 });
    }
    Ok(n as u16)
}

fn main() {
    match parse_port("8080") {
        Ok(p)  => println!("port: {}", p),
        Err(e) => eprintln!("error: {}", e),
    }

    match parse_port("99999") {
        Ok(p)  => println!("port: {}", p),
        Err(e) => eprintln!("error: {}", e), // 99999 is out of range [1, 65535]
    }
}

Using thiserror for Cleaner Custom Errors

Writing Display, Error, and From implementations by hand is repetitive boilerplate. The thiserror crate generates all of it from annotations on your enum. This is the standard choice for library crates that need structured, typed errors without the ceremony.

[dependencies]
thiserror = "1"
use thiserror::Error;
use std::num::ParseIntError;

#[derive(Debug, Error)]
enum AppError {
    // #[error("...")] generates the Display impl
    // #[from] generates the From impl and wires it to ?
    #[error("parse error: {0}")]
    Parse(#[from] ParseIntError),

    #[error("{value} is out of range [{min}, {max}]")]
    OutOfRange { value: i32, min: i32, max: i32 },

    #[error("'{0}' not found")]
    NotFound(String),

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

fn parse_port(s: &str) -> Result<u16, AppError> {
    let n: i32 = s.parse()?; // auto-converts via #[from] on Parse variant
    if !(1..=65535).contains(&n) {
        return Err(AppError::OutOfRange { value: n, min: 1, max: 65535 });
    }
    Ok(n as u16)
}

Using anyhow for Application Code

Library crates need typed errors so callers can react programmatically. Application code (binaries, CLI tools, scripts) usually just needs to display errors clearly and propagate them to main. The anyhow crate provides a single Error type that wraps any error, plus a .context() method that attaches human-readable explanation at each layer of the call stack.

[dependencies]
anyhow = "1"
use anyhow::{Context, Result, bail};

fn read_config(path: &str) -> Result<String> {
    // .with_context() adds a message that appears when the error is printed
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("failed to read config from {}", path))?;

    if content.is_empty() {
        // bail! is shorthand for return Err(anyhow!(...))
        bail!("config file is empty");
    }

    Ok(content)
}

fn main() -> Result<()> {
    // Errors chain: "application startup failed: failed to read config from ...: ..."
    let config = read_config("config.toml")
        .context("application startup failed")?;

    println!("config loaded: {} bytes", config.len());
    Ok(())
}

Combining thiserror and anyhow

The two crates are complementary and designed to be used together in larger projects. Use thiserror in library crates to give callers structured errors they can match on. Use anyhow in binary crates to accept errors from multiple libraries, wrap them with context, and present them to the user.

// lib.rs — structured errors that library consumers can match on
#[derive(Debug, thiserror::Error)]
pub enum DbError {
    #[error("connection failed: {0}")]
    Connection(String),
    #[error("query failed: {0}")]
    Query(String),
}

// main.rs — application combines errors from many libraries via anyhow
use anyhow::Result;

fn run() -> Result<()> {
    // DbError automatically converts to anyhow::Error via the From blanket impl
    let _ = connect_db()?;
    Ok(())
}

Error Handling Summary

ToolUse case
panic!Bugs, broken invariants, “can’t happen”
Result<T, E>All recoverable errors
? operatorPropagate errors up the stack
unwrap / expectPrototyping, tests, when None/Err is truly impossible
Custom enum + thiserrorLibrary errors with structured variants
anyhowApplication code, scripts, main functions

Frequently Asked Questions

When should I use panic! vs Result?
Use panic! for unrecoverable bugs (programming errors, broken invariants). Use Result for expected failures that callers should handle (IO errors, parse errors, network failures).
What does the ? operator do?
? unwraps Ok(v) to v, and for Err(e) it converts the error using From and returns early from the current function. It only works in functions returning Result or Option.
What is the difference between thiserror and anyhow?
thiserror is for library authors — it generates Display and Error implementations for custom error types. anyhow is for applications — it provides a single error type that can hold any error with context.