Skip to content
Codeloom
Rust

Error Handling with Result and Option in Rust

Handle errors idiomatically in Rust — Result, Option, the ? operator, custom error types, and the thiserror/anyhow ecosystem.

·4 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • How Result and Option replace exceptions and null
  • The ? operator for concise error propagation
  • Custom error types with Display and Error traits
  • When to use thiserror vs anyhow

Prerequisites

  • Rust basics (enums, pattern matching, traits)
  • Basic understanding of generics

Rust has no exceptions and no null. Instead, it uses two enums — Result<T, E> and Option<T> — to represent operations that can fail or return nothing. The compiler forces you to handle both cases.

Option: when a value might not exist

enum Option<T> {
    Some(T),
    None,
}
fn find_user(id: u32) -> Option<&'static str> {
    match id {
        1 => Some("Alice"),
        2 => Some("Bob"),
        _ => None,
    }
}

let user = find_user(3);
match user {
    Some(name) => println!("Found: {name}"),
    None => println!("Not found"),
}

Useful Option methods

let x: Option<i32> = Some(42);

x.unwrap();           // 42 — panics if None
x.unwrap_or(0);       // 42 — returns 0 if None
x.unwrap_or_default();// 42 — returns T::default() if None
x.map(|n| n * 2);     // Some(84)
x.and_then(|n| if n > 0 { Some(n) } else { None }); // Some(42)
x.is_some();          // true
x.is_none();          // false

Result: when an operation can fail

enum Result<T, E> {
    Ok(T),
    Err(E),
}
use std::fs;
use std::io;

fn read_config() -> Result<String, io::Error> {
    fs::read_to_string("config.toml")
}

match read_config() {
    Ok(contents) => println!("Config:\n{contents}"),
    Err(e) => eprintln!("Failed to read config: {e}"),
}

Useful Result methods

let r: Result<i32, String> = Ok(42);

r.unwrap();           // 42 — panics on Err
r.expect("must work");// 42 — panics with custom message on Err
r.unwrap_or(0);       // 42
r.map(|n| n * 2);     // Ok(84)
r.map_err(|e| format!("Error: {e}")); // transform the error
r.is_ok();            // true
r.is_err();           // false
r.ok();               // Some(42) — converts to Option

The ? operator

The ? operator propagates errors — if the expression is Err, the function returns early with that error.

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

This is equivalent to:

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

Chaining with ?

fn setup_app() -> Result<Config, Box<dyn std::error::Error>> {
    let raw = fs::read_to_string("config.toml")?;
    let config: Config = toml::from_str(&raw)?;
    let db = Database::connect(&config.db_url)?;
    Ok(config)
}

Each ? either continues with the success value or returns the error immediately.

Custom error types

The Error trait

use std::fmt;

#[derive(Debug)]
enum AppError {
    NotFound(String),
    ParseError(String),
    IoError(std::io::Error),
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            AppError::NotFound(msg) => write!(f, "not found: {msg}"),
            AppError::ParseError(msg) => write!(f, "parse error: {msg}"),
            AppError::IoError(e) => write!(f, "I/O error: {e}"),
        }
    }
}

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

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

The From impl lets ? automatically convert io::Error into AppError.

thiserror: less boilerplate

The thiserror crate derives Display and Error for you.

use thiserror::Error;

#[derive(Error, Debug)]
enum AppError {
    #[error("not found: {0}")]
    NotFound(String),

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

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

    #[error("database error")]
    Db(#[from] sqlx::Error),
}

Use thiserror for library code where callers need to inspect error variants.

anyhow: quick and easy

The anyhow crate provides anyhow::Result — a type-erased error that works with any error type.

use anyhow::{Context, Result};

fn setup() -> Result<()> {
    let config = fs::read_to_string("config.toml")
        .context("failed to read config file")?;

    let port: u16 = config
        .parse()
        .context("invalid port number")?;

    Ok(())
}

Use anyhow for application code where you just want to propagate errors with context.

Option to Result conversion

let user: Option<&str> = find_user(42);

// Convert Option to Result
let user = user.ok_or("user not found")?;
let user = user.ok_or_else(|| AppError::NotFound("user 42".into()))?;

When to panic

Use panic!, unwrap(), or expect() only when:

  • A bug in your code makes the error impossible (invariant violation).
  • You are in tests.
  • You are in a prototype.

Never panic in library code on user input.

Summary

Rust replaces exceptions with Result and null with Option. The ? operator makes error propagation concise. Use thiserror for libraries (callers need typed errors) and anyhow for applications (you just want to propagate and display). The compiler ensures you handle every error — nothing slips through silently.