Rust - Error Handling (Result, Option, panic)

Overview

Estimated time: 60–75 minutes

Master Rust's approach to error handling without exceptions using Result<T,E> and Option<T>, understand when to use panic!, and learn error propagation patterns that make Rust code both safe and expressive.

Learning Objectives

Prerequisites

Option<T> - Handling Absence of Values

Rust doesn't have null values. Instead, it uses Option<T> to represent values that might be absent:

// Option is defined as:
enum Option<T> {
    Some(T),
    None,
}

fn main() {
    let some_number = Some(5);
    let some_string = Some("hello");
    let absent_number: Option<i32> = None;
    
    println!("some_number: {:?}", some_number);
    println!("absent_number: {:?}", absent_number);
}

Expected output:

some_number: Some(5)
absent_number: None

Working with Option Values

fn divide(numerator: f64, denominator: f64) -> Option<f64> {
    if denominator == 0.0 {
        None
    } else {
        Some(numerator / denominator)
    }
}

fn main() {
    let result = divide(10.0, 3.0);
    
    match result {
        Some(value) => println!("Result: {}", value),
        None => println!("Cannot divide by zero!"),
    }
    
    // Using if let for simpler cases
    if let Some(value) = divide(20.0, 4.0) {
        println!("Division result: {}", value);
    }
    
    // Using unwrap_or for default values
    let safe_result = divide(15.0, 0.0).unwrap_or(0.0);
    println!("Safe result: {}", safe_result);
}

Expected output:

Result: 3.3333333333333335
Division result: 5
Safe result: 0

Result<T,E> - Recoverable Error Handling

Result<T,E> represents operations that can succeed or fail:

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let file_result = File::open("hello.txt");
    
    let file = match file_result {
        Ok(file) => file,
        Err(error) => match error.kind() {
            ErrorKind::NotFound => {
                println!("File not found, creating a new one...");
                match File::create("hello.txt") {
                    Ok(fc) => fc,
                    Err(e) => panic!("Problem creating file: {:?}", e),
                }
            }
            other_error => {
                panic!("Problem opening file: {:?}", other_error)
            }
        }
    };
    
    println!("File handled successfully");
}

Custom Result Functions

#[derive(Debug)]
enum MathError {
    DivisionByZero,
    NegativeSquareRoot,
}

fn safe_divide(a: f64, b: f64) -> Result<f64, MathError> {
    if b == 0.0 {
        Err(MathError::DivisionByZero)
    } else {
        Ok(a / b)
    }
}

fn sqrt(x: f64) -> Result<f64, MathError> {
    if x < 0.0 {
        Err(MathError::NegativeSquareRoot)
    } else {
        Ok(x.sqrt())
    }
}

fn main() {
    // Successful operations
    match safe_divide(10.0, 2.0) {
        Ok(result) => println!("10 / 2 = {}", result),
        Err(e) => println!("Error: {:?}", e),
    }
    
    // Error cases
    match safe_divide(10.0, 0.0) {
        Ok(result) => println!("Result: {}", result),
        Err(e) => println!("Division error: {:?}", e),
    }
    
    match sqrt(-4.0) {
        Ok(result) => println!("Square root: {}", result),
        Err(e) => println!("Math error: {:?}", e),
    }
}

Expected output:

10 / 2 = 5
Division error: DivisionByZero
Math error: NegativeSquareRoot

Error Propagation with the ? Operator

The ? operator simplifies error propagation:

use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut file = File::open("username.txt")?;
    let mut username = String::new();
    file.read_to_string(&mut username)?;
    Ok(username)
}

// Equivalent to the above without ? operator:
fn read_username_verbose() -> Result<String, io::Error> {
    let file_result = File::open("username.txt");
    let mut file = match file_result {
        Ok(file) => file,
        Err(e) => return Err(e),
    };
    
    let mut username = String::new();
    match file.read_to_string(&mut username) {
        Ok(_) => Ok(username),
        Err(e) => Err(e),
    }
}

fn main() {
    match read_username_from_file() {
        Ok(username) => println!("Username: {}", username.trim()),
        Err(e) => println!("Failed to read username: {}", e),
    }
}

Chaining Operations with ?

fn calculate_complex(a: f64, b: f64, c: f64) -> Result<f64, MathError> {
    let division_result = safe_divide(a, b)?;
    let sqrt_result = sqrt(division_result)?;
    safe_divide(sqrt_result, c)
}

fn main() {
    match calculate_complex(100.0, 4.0, 2.0) {
        Ok(result) => println!("Complex calculation: {}", result),
        Err(e) => println!("Calculation failed: {:?}", e),
    }
    
    // This will fail at the division by zero
    match calculate_complex(100.0, 0.0, 2.0) {
        Ok(result) => println!("Result: {}", result),
        Err(e) => println!("Error occurred: {:?}", e),
    }
}

Expected output:

Complex calculation: 2.5
Error occurred: DivisionByZero

When to Use panic!

panic! should be used for unrecoverable errors where the program cannot continue:

fn main() {
    // Use panic! for programming errors
    let v = vec![1, 2, 3];
    
    // This would panic with index out of bounds
    // v[99]; // Don't do this!
    
    // Better: use get() which returns Option
    match v.get(99) {
        Some(value) => println!("Value: {}", value),
        None => println!("Index out of bounds"),
    }
    
    // Explicit panic for unrecoverable situations
    let user_input = "not_a_number";
    let parsed: Result<i32, _> = user_input.parse();
    
    match parsed {
        Ok(num) => println!("Parsed number: {}", num),
        Err(_) => {
            println!("Invalid input, cannot continue");
            // In a real app, you might want to prompt again instead
            // panic!("Critical error: invalid user input");
        }
    }
}

Unwrap and Expect

fn main() {
    let some_value = Some(42);
    let no_value: Option<i32> = None;
    
    // unwrap() panics if None
    let value = some_value.unwrap();
    println!("Unwrapped value: {}", value);
    
    // expect() panics with a custom message
    let file = File::open("config.txt")
        .expect("Failed to open config.txt - make sure it exists");
    
    // Safer alternatives
    let safe_value = no_value.unwrap_or(0);
    println!("Safe value: {}", safe_value);
    
    let computed_value = no_value.unwrap_or_else(|| {
        println!("Computing default value...");
        42
    });
    println!("Computed value: {}", computed_value);
}

Custom Error Types

use std::fmt;

#[derive(Debug)]
enum CalculatorError {
    DivisionByZero,
    InvalidOperation(String),
    ParseError(String),
}

impl fmt::Display for CalculatorError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            Self::DivisionByZero => write!(f, "Cannot divide by zero"),
            Self::InvalidOperation(op) => write!(f, "Invalid operation: {}", op),
            Self::ParseError(input) => write!(f, "Cannot parse '{}' as number", input),
        }
    }
}

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

fn calculate(expression: &str) -> Result<f64, CalculatorError> {
    let parts: Vec<&str> = expression.split_whitespace().collect();
    
    if parts.len() != 3 {
        return Err(CalculatorError::InvalidOperation(expression.to_string()));
    }
    
    let a: f64 = parts[0].parse()
        .map_err(|_| CalculatorError::ParseError(parts[0].to_string()))?;
    let operator = parts[1];
    let b: f64 = parts[2].parse()
        .map_err(|_| CalculatorError::ParseError(parts[2].to_string()))?;
    
    match operator {
        "+" => Ok(a + b),
        "-" => Ok(a - b),
        "*" => Ok(a * b),
        "/" => {
            if b == 0.0 {
                Err(CalculatorError::DivisionByZero)
            } else {
                Ok(a / b)
            }
        }
        _ => Err(CalculatorError::InvalidOperation(operator.to_string())),
    }
}

fn main() {
    let expressions = vec![
        "10 + 5",
        "20 / 4",
        "15 / 0",
        "not_a_number + 5",
        "10 % 3",  // Invalid operator
    ];
    
    for expr in expressions {
        match calculate(expr) {
            Ok(result) => println!("{} = {}", expr, result),
            Err(e) => println!("Error in '{}': {}", expr, e),
        }
    }
}

Expected output:

10 + 5 = 15
20 / 4 = 5
Error in '15 / 0': Cannot divide by zero
Error in 'not_a_number + 5': Cannot parse 'not_a_number' as number
Error in '10 % 3': Invalid operation: %

Common Pitfalls

1. Overusing unwrap()

// Avoid this - can cause panic
let value = some_option.unwrap();

// Better - handle the None case
let value = match some_option {
    Some(v) => v,
    None => return, // or handle appropriately
};

// Or use unwrap_or for defaults
let value = some_option.unwrap_or(default_value);

2. Not Using the ? Operator

// Verbose and error-prone
fn process_file() -> Result<String, io::Error> {
    let file = match File::open("data.txt") {
        Ok(f) => f,
        Err(e) => return Err(e),
    };
    // ... more match statements
}

// Clean and idiomatic
fn process_file() -> Result<String, io::Error> {
    let mut file = File::open("data.txt")?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    Ok(contents)
}

3. Using panic! for Recoverable Errors

// Avoid - terminates the program
fn divide(a: f64, b: f64) -> f64 {
    if b == 0.0 {
        panic!("Division by zero!");
    }
    a / b
}

// Better - return a Result
fn divide(a: f64, b: f64) -> Result<f64, &'static str> {
    if b == 0.0 {
        Err("Division by zero")
    } else {
        Ok(a / b)
    }
}

Best Practices

Checks for Understanding

Question 1: Option vs Result

When would you use Option<T> vs Result<T,E>?

Answer

Use Option<T> when a value might be absent but there's no error condition (like searching in a list). Use Result<T,E> when an operation can fail and you need to know why it failed (like file operations or parsing).

Question 2: Error Propagation

What does the ? operator do and when can you use it?

Answer

The ? operator unwraps successful values and automatically returns errors. It can only be used in functions that return Result or Option, and the error types must be compatible.

Question 3: Custom Errors

What traits should a custom error type implement?

Answer

Custom error types should implement Debug, Display, and std::error::Error. This makes them compatible with Rust's error handling ecosystem and provides good error messages.


← PreviousNext →