On this page

Error handling: Result, Option, and the ? operator

14 min read TextCh. 4 — Abstractions

Error handling in Rust

Rust has no exceptions. Instead, it uses the type system to represent operations that can fail: Result<T, E> and Option<T>. This approach forces the programmer to consider error cases explicitly, eliminating a whole category of bugs.

Review: Result and Option

// In the stdlib:
// enum Result<T, E> { Ok(T), Err(E) }
// enum Option<T> { Some(T), None }

fn main() {
    // Result for operations that can fail with a specific error
    let success: Result<i32, &str> = Ok(42);
    let failure: Result<i32, &str> = Err("something went wrong");
    
    // Option for values that may not exist
    let present: Option<&str> = Some("hello");
    let absent: Option<&str> = None;
    
    // Both are normal enums — handled with match
    match success {
        Ok(n)  => println!("Success: {n}"),
        Err(e) => println!("Error: {e}"),
    }
    
    match present {
        Some(s) => println!("Value: {s}"),
        None    => println!("No value"),
    }
}

The `?` operator: elegant error propagation

The ? operator is the most important piece of error handling in Rust. It does two things:

  1. If the value is Ok(T), it extracts T and continues
  2. If the value is Err(E), it converts the error (via From) and returns immediately
use std::fs;
use std::io;

// Without ? — verbose and noisy
fn read_file_verbose(path: &str) -> Result<String, io::Error> {
    let content = match fs::read_to_string(path) {
        Ok(s)  => s,
        Err(e) => return Err(e),
    };
    Ok(content.to_uppercase())
}

// With ? — clean and direct
fn read_file(path: &str) -> Result<String, io::Error> {
    let content = fs::read_to_string(path)?;
    Ok(content.to_uppercase())
}

// Chaining multiple operations that can fail
fn process_number(s: &str) -> Result<f64, Box<dyn std::error::Error>> {
    let n: i32 = s.trim().parse()?; // ParseIntError → Box<dyn Error>
    let root = if n >= 0 {
        (n as f64).sqrt()
    } else {
        return Err("Cannot compute square root of negative".into());
    };
    Ok(root)
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    match process_number("  16  ") {
        Ok(r) => println!("Square root: {r}"),
        Err(e) => println!("Error: {e}"),
    }
    
    match process_number("abc") {
        Ok(r) => println!("Square root: {r}"),
        Err(e) => println!("Error: {e}"),
    }
    
    Ok(())
}

Custom error types

For real projects, you will want to create descriptive error types:

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

#[derive(Debug)]
enum AppError {
    InvalidInput { field: String, message: String },
    Database(String),
    Network { code: u16, url: String },
    Parse(ParseIntError),
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            AppError::InvalidInput { field, message } => {
                write!(f, "Invalid input for '{field}': {message}")
            }
            AppError::Database(msg) => write!(f, "Database error: {msg}"),
            AppError::Network { code, url } => {
                write!(f, "Network error {code} connecting to {url}")
            }
            AppError::Parse(e) => write!(f, "Parse error: {e}"),
        }
    }
}

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

// Automatic conversion from ParseIntError
impl From<ParseIntError> for AppError {
    fn from(e: ParseIntError) -> Self {
        AppError::Parse(e)
    }
}

fn validate_age(s: &str) -> Result<u8, AppError> {
    let age: i32 = s.parse()?; // ParseIntError is converted via From
    
    if age < 0 || age > 150 {
        return Err(AppError::InvalidInput {
            field: String::from("age"),
            message: format!("{age} is not a valid age (0-150)"),
        });
    }
    
    Ok(age as u8)
}

fn main() {
    for input in ["25", "abc", "-5", "200"] {
        match validate_age(input) {
            Ok(age) => println!("Valid age: {age}"),
            Err(e)  => println!("Error: {e}"),
        }
    }
}

Useful methods on Result and Option

fn main() {
    let ok: Result<i32, &str> = Ok(42);
    let err: Result<i32, &str> = Err("failure");
    
    // --- Result methods ---
    
    // unwrap: extract value or panic
    println!("{}", ok.unwrap());
    
    // expect: like unwrap but with custom message
    println!("{}", ok.expect("Should be Ok"));
    
    // unwrap_or: default value if Err
    println!("{}", err.unwrap_or(0));
    
    // unwrap_or_else: compute default value
    println!("{}", err.unwrap_or_else(|e| { println!("Error: {e}"); -1 }));
    
    // map: transform the Ok value
    let doubled = ok.map(|n| n * 2);
    println!("{:?}", doubled);
    
    // map_err: transform the error
    let transformed = err.map_err(|e| format!("ERROR: {e}"));
    println!("{:?}", transformed);
    
    // and_then: chain Results
    let result = ok
        .and_then(|n| if n > 0 { Ok(n * 10) } else { Err("negative") })
        .and_then(|n| Ok(format!("Result: {n}")));
    println!("{:?}", result);
    
    // is_ok / is_err
    println!("Is ok Ok? {}", ok.is_ok());
    println!("Is err Err? {}", err.is_err());
    
    // --- Option methods ---
    let some: Option<i32> = Some(10);
    let none: Option<i32> = None;
    
    println!("{}", some.unwrap_or(0));
    println!("{}", none.unwrap_or_default()); // i32::default() = 0
    
    // ok_or: convert Option to Result
    let r: Result<i32, &str> = none.ok_or("no value");
    println!("{:?}", r);
    
    // filter: keep Some only if condition is met
    let even = some.filter(|n| n % 2 == 0);
    println!("{:?}", even); // Some(10) — 10 is even
    
    // zip: combine two Options
    let a = Some(1);
    let b = Some("hello");
    println!("{:?}", a.zip(b)); // Some((1, "hello"))
}

Panic: when and when not to use it

fn main() {
    // panic! for situations that "should never happen"
    // (programming errors, not user errors)
    let v = vec![1, 2, 3];
    // v[10]; // panic: index out of bounds
    
    // assert! for preconditions
    fn calculate_root(n: f64) -> f64 {
        assert!(n >= 0.0, "Cannot calculate root of {n}");
        n.sqrt()
    }
    
    // todo! for not-yet-implemented code
    fn pending_function() -> i32 {
        todo!("Implement business logic")
    }
    
    // unreachable! for branches that should never be reached
    let state = 1_u8;
    let message = match state {
        0 => "inactive",
        1 => "active",
        2 => "suspended",
        _ => unreachable!("Invalid state: {state}"),
    };
    
    println!("State: {message}");
    println!("{:.4}", calculate_root(16.0));
}

With robust error handling in your arsenal, the next lesson explores modules and crates — how Rust organizes code into reusable units.

The ? operator only works in functions returning Result or Option
If you use ? in main(), change its signature to fn main() -> Result<(), Box<dyn std::error::Error>>. This allows propagating errors directly from main and they get printed automatically if they occur.
Avoid unwrap() and expect() in production code
unwrap() and expect() panic if the value is Err or None. They are useful in prototypes, tests, and when you are absolutely certain the value exists. In production code, propagate the error with ? or handle it with match/if let.
rust
use std::num::ParseIntError;
use std::fmt;

// Custom error type
#[derive(Debug)]
enum CalcError {
    DivisionByZero,
    InvalidNumber(ParseIntError),
    NegativeNotAllowed(i64),
}

impl fmt::Display for CalcError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::DivisionByZero => write!(f, "Division by zero"),
            Self::InvalidNumber(e) => write!(f, "Invalid number: {e}"),
            Self::NegativeNotAllowed(n) => {
                write!(f, "Negative not allowed: {n}")
            }
        }
    }
}

impl From<ParseIntError> for CalcError {
    fn from(e: ParseIntError) -> Self {
        CalcError::InvalidNumber(e)
    }
}

fn divide(a: i64, b: i64) -> Result<i64, CalcError> {
    if b == 0 { return Err(CalcError::DivisionByZero); }
    if a < 0  { return Err(CalcError::NegativeNotAllowed(a)); }
    Ok(a / b)
}

fn parse_and_divide(a: &str, b: &str) -> Result<i64, CalcError> {
    let a: i64 = a.trim().parse()?; // ? converts via From
    let b: i64 = b.trim().parse()?;
    divide(a, b)
}

fn main() {
    let cases = [("100", "4"), ("abc", "2"), ("50", "0"), ("-10", "2")];
    for (a, b) in &cases {
        match parse_and_divide(a, b) {
            Ok(r)  => println!("{a}/{b} = {r}"),
            Err(e) => println!("Error: {e}"),
        }
    }
}