Intermediate Topics: Error Handling with `Result` and `Option`

Intermediate Topics: Error Handling with Result and Option

Rust’s approach to error handling is one of its most celebrated features, prioritizing explicitness and compile-time guarantees over runtime exceptions. This chapter will deepen your understanding of how Rust manages errors using the Result<T, E> and Option<T> enums, and how to effectively use the ? operator for error propagation.

Recoverable vs. Unrecoverable Errors

Rust categorizes errors into two main types:

  1. Recoverable Errors: These are problems that are likely to happen, but that you can and should respond to. Examples include file not found, incorrect user input, or network connection issues. Rust handles these with the Result<T, E> enum.

  2. Unrecoverable Errors: These are typically bugs in your code that put the program in an invalid state, where there’s no way to proceed safely. Examples include out-of-bounds array access, impossible conditions, or unexpected None values where a Some was guaranteed. Rust handles these by panicking.

panic! for Unrecoverable Errors

When a program panic!s, it will unwind the stack, clean up memory, and then exit. This is a deliberate choice to ensure safety when an invariant is violated.

fn main() {
    // This will cause a panic!
    // panic!("Crash and burn!");

    let v = vec![1, 2, 3];
    // v[100]; // This would also panic at runtime due to out-of-bounds access
}

You should use panic! sparingly, typically for cases that indicate a fundamental bug in your logic or an unrecoverable situation. For example, if you assume a value must be present, but it turns out to be None, a panic! can immediately flag this programming error.

unwrap() and expect()

Convenience methods like unwrap() and expect() are available on Option and Result. They extract the inner value if it’s Some or Ok, but panic! if it’s None or Err.

  • unwrap(): Panics with a default message if None or Err.
  • expect("custom message"): Panics with your custom message if None or Err. This is generally preferred over unwrap() because the custom message makes debugging easier.
fn main() {
    let some_value = Some(5);
    let value = some_value.unwrap(); // `value` is 5
    println!("Unwrapped value: {}", value);

    let no_value: Option<i32> = None;
    // let value = no_value.unwrap(); // PANICS!

    let result_ok: Result<String, &str> = Ok(String::from("Success!"));
    let message = result_ok.expect("Should have been an Ok variant!");
    println!("Result message: {}", message);

    let result_err: Result<String, &str> = Err("File not found");
    // let message = result_err.expect("Should have been an Ok variant!"); // PANICS with "Should have been an Ok variant!"
}

Use unwrap() and expect() only when you are absolutely certain that the Option will be Some or the Result will be Ok, or in test code where a panic is acceptable for indicating a test failure.

Result<T, E> for Recoverable Errors

The Result enum is the primary tool for handling errors that your program can potentially recover from. Its definition is:

enum Result<T, E> {
    Ok(T), // T is the type of the successful value
    Err(E), // E is the type of the error value
}

When a function might fail, it should return Result<T, E>. The calling code is then forced by the compiler to consider both the Ok and Err cases.

Using match with Result

The match expression is the most explicit way to handle Result.

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

fn main() {
    let file_result = File::open("hello.txt"); // Returns a Result<File, std::io::Error>

    match file_result {
        Ok(file) => {
            println!("File opened successfully: {:?}", file);
        }
        Err(error) => match error.kind() { // Nested match for specific error kinds
            ErrorKind::NotFound => {
                println!("File not found, creating it...");
                match File::create("hello.txt") {
                    Ok(new_file) => println!("File created: {:?}", new_file),
                    Err(e) => eprintln!("Problem creating file: {:?}", e),
                }
            }
            other_error => {
                eprintln!("Problem opening file: {:?}", other_error);
            }
        },
    }
}

This demonstrates how you can robustly handle different types of I/O errors.

The ? Operator for Error Propagation

The ? operator is syntactic sugar that simplifies error propagation from functions that return Result. It acts as an early return for Err values.

When you use ? on a Result:

  • If the Result is Ok(value), the value is extracted and the function continues.
  • If the Result is Err(error), the error is returned immediately from the current function.

Crucially, the function where you use ? must return a Result (or Option for Option propagation) where the Err type is compatible with the error type being propagated (or convertible via the From trait).

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

fn read_username_from_file() -> Result<String, io::Error> {
    let mut f = File::open("username.txt")?; // If Err, returns Err from this function
    let mut username = String::new();
    f.read_to_string(&mut username)?; // If Err, returns Err from this function
    Ok(username) // If both succeed, return Ok with the username
}

fn main() {
    match read_username_from_file() {
        Ok(username) => println!("Username: {}", username),
        Err(e) => eprintln!("Error reading username: {}", e),
    }

    // Create a dummy file for testing success
    if let Err(e) = File::create("username.txt").and_then(|mut f| f.write_all(b"alice")) {
        eprintln!("Failed to prepare username.txt: {}", e);
    }
    match read_username_from_file() {
        Ok(username) => println!("Username: {}", username),
        Err(e) => eprintln!("Error reading username: {}", e),
    }
}

Without ?, the read_username_from_file function would be much more verbose, requiring nested match statements.

Chaining with Result methods

Result also has useful methods for chaining operations:

  • .ok(): Converts Result<T, E> to Option<T>, discarding the error if Err.
  • .err(): Converts Result<T, E> to Option<E>, discarding the value if Ok.
  • .map(|value| ...): Transforms the Ok value if present, otherwise propagates Err.
  • .map_err(|error| ...): Transforms the Err value if present, otherwise propagates Ok.
  • .and_then(|value| -> Result<U, E> { ... }): Chains operations that both return Result. If the first is Ok, the closure is run; otherwise, Err is propagated.
  • .or_else(|error| -> Result<T, F> { ... }): Chains operations that both return Result. If the first is Err, the closure is run; otherwise, Ok is propagated.
fn parse_and_double(s: &str) -> Result<i32, String> {
    s.parse::<i32>()
        .map_err(|e| format!("Failed to parse: {}", e)) // Convert ParseIntError to String
        .and_then(|num| { // Only runs if parsing was Ok
            if num > 1000 {
                Err(String::from("Number is too large!"))
            } else {
                Ok(num * 2)
            }
        })
}

fn main() {
    println!("{:?}", parse_and_double("10")); // Ok(20)
    println!("{:?}", parse_and_double("abc")); // Err("Failed to parse: invalid digit found in string")
    println!("{:?}", parse_and_double("2000")); // Err("Number is too large!")
}

Custom Error Types

For application-specific errors, it’s good practice to define your own custom error types. This improves clarity and allows for more precise error handling.

Often, you’ll create an enum for your custom errors and implement the std::fmt::Display and std::error::Error traits on it. The thiserror and anyhow crates are very popular for simplifying custom error creation and propagation.

use std::fmt;
use std::num::ParseIntError; // To include a parsing error
use std::error::Error; // The standard error trait

#[derive(Debug)] // Needed for {:?} formatting
enum AppError {
    Io(io::Error), // Wrap std::io::Error
    Parse(ParseIntError), // Wrap std::num::ParseIntError
    Config(String), // Custom configuration error
}

// Implement Display trait for user-friendly error messages
impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            AppError::Io(e) => write!(f, "I/O error: {}", e),
            AppError::Parse(e) => write!(f, "Parsing error: {}", e),
            AppError::Config(msg) => write!(f, "Configuration error: {}", msg),
        }
    }
}

// Implement the Error trait for error chaining and integration
impl Error for AppError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            AppError::Io(e) => Some(e),
            AppError::Parse(e) => Some(e),
            _ => None, // Config error doesn't have an underlying source
        }
    }
}

// Implement `From` trait for automatic conversion with `?`
impl From<io::Error> for AppError {
    fn from(error: io::Error) -> Self {
        AppError::Io(error)
    }
}

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

// A function that might return our custom error type
fn process_config_file(path: &str) -> Result<i32, AppError> {
    let contents = std::fs::read_to_string(path)?; // `?` will convert io::Error to AppError::Io
    let value: i32 = contents.trim().parse()?; // `?` will convert ParseIntError to AppError::Parse

    if value < 0 {
        return Err(AppError::Config(String::from("Value cannot be negative")));
    }
    Ok(value)
}

fn main() {
    // Test with a non-existent file
    match process_config_file("non_existent.txt") {
        Ok(value) => println!("Config value: {}", value),
        Err(e) => eprintln!("Application error: {}", e),
    }

    // Create a dummy file for testing valid input
    if let Err(e) = File::create("valid_config.txt").and_then(|mut f| f.write_all(b"123\n")) {
        eprintln!("Failed to prepare valid_config.txt: {}", e);
    }
    match process_config_file("valid_config.txt") {
        Ok(value) => println!("Config value: {}", value),
        Err(e) => eprintln!("Application error: {}", e),
    }

    // Test with invalid content
    if let Err(e) = File::create("invalid_content.txt").and_then(|mut f| f.write_all(b"abc\n")) {
        eprintln!("Failed to prepare invalid_content.txt: {}", e);
    }
    match process_config_file("invalid_content.txt") {
        Ok(value) => println!("Config value: {}", value),
        Err(e) => eprintln!("Application error: {}", e),
    }

    // Test with negative value
    if let Err(e) = File::create("negative_value.txt").and_then(|mut f| f.write_all(b"-5\n")) {
        eprintln!("Failed to prepare negative_value.txt: {}", e);
    }
    match process_config_file("negative_value.txt") {
        Ok(value) => println!("Config value: {}", value),
        Err(e) => eprintln!("Application error: {}", e),
    }
}

By implementing From for our custom error, we enable seamless error conversion with the ? operator. This makes your error handling code clean and type-safe.

Exercises / Mini-Challenges

Exercise 7.1: Safe Division Function

Write a function safe_divide(numerator: f64, denominator: f64) -> Option<f64> that performs division.

  • If the denominator is 0, it should return None.
  • Otherwise, it should return Some(result).

In main, call this function with various inputs (including denominator = 0.0) and use if let to print the result or an appropriate message.

Instructions:

  1. Define the safe_divide function.
  2. In main, test safe_divide(10.0, 2.0), safe_divide(7.0, 0.0), and safe_divide(100.0, 4.0).
  3. For each call, use if let to display the result or “Division by zero is not allowed.”
// Solution Hint:
/*
fn safe_divide(numerator: f64, denominator: f64) -> Option<f64> {
    if denominator == 0.0 {
        None
    } else {
        Some(numerator / denominator)
    }
}

fn main() {
    let result1 = safe_divide(10.0, 2.0);
    if let Some(val) = result1 {
        println!("10.0 / 2.0 = {}", val);
    } else {
        println!("Division by zero is not allowed.");
    }

    // Test other cases
}
*/

Exercise 7.2: File Word Counter with Error Handling

Write a function count_words_in_file(filename: &str) -> Result<usize, io::Error> that reads a file and returns the total number of words.

  • Use std::fs::read_to_string to read the file.
  • Handle potential io::Error using the ? operator.
  • If the file is read successfully, count the words (split by whitespace) and return Ok(word_count).

In main, call this function with both an existing file (create one first, e.g., test.txt) and a non-existent file, printing appropriate success or error messages.

Instructions:

  1. Create a test.txt file in your project directory with some content (e.g., “Hello Rust. This is a test file.”).
  2. Define count_words_in_file.
  3. Inside count_words_in_file:
    • Read the file content using read_to_string().
    • If successful, split the content by whitespace and count the resulting elements.
  4. In main, call count_words_in_file("test.txt") and count_words_in_file("non_existent.txt").
  5. Use a match expression for each call to handle Ok (print word count) and Err (print error message).
// Solution Hint:
/*
use std::fs;
use std::io;

fn count_words_in_file(filename: &str) -> Result<usize, io::Error> {
    let contents = fs::read_to_string(filename)?; // ? for io::Error propagation
    let word_count = contents.split_whitespace().count();
    Ok(word_count)
}

fn main() {
    // Make sure 'test.txt' exists with content
    if let Err(e) = fs::write("test.txt", "Rust is an amazing programming language.") {
        eprintln!("Failed to write test.txt: {}", e);
    }

    match count_words_in_file("test.txt") {
        Ok(count) => println!("'test.txt' has {} words.", count),
        Err(e) => eprintln!("Error counting words in 'test.txt': {}", e),
    }

    match count_words_in_file("non_existent.txt") {
        Ok(count) => println!("'non_existent.txt' has {} words.", count), // This won't run
        Err(e) => eprintln!("Error counting words in 'non_existent.txt': {}", e),
    }
}
*/

Exercise 7.3: User Profile Loader with Custom Errors

Create a simple user profile system. Define a Profile struct with username: String and age: u8. Implement a function load_profile(username_input: &str, age_input: &str) -> Result<Profile, ProfileError> where ProfileError is a custom enum you define.

ProfileError should have the following variants:

  • InvalidUsername(String): If username is empty.
  • InvalidAge(ParseIntError): If age parsing fails or age is 0.
  • Io(io::Error): If file operations fail (you can optionally load from a file later, but for this exercise, you can skip file interaction and just simulate the errors).

Instructions:

  1. Define the Profile struct and ProfileError enum.
  2. Implement std::fmt::Display and std::error::Error for ProfileError.
  3. Implement From<ParseIntError> for ProfileError.
  4. Define load_profile:
    • Check if username_input is empty. If so, return Err(ProfileError::InvalidUsername).
    • Parse age_input to u8. Use ? for propagation. If the resulting age is 0, return Err(ProfileError::InvalidAge) (you’ll need to manually check for age 0 after parsing).
    • If all checks pass, return Ok(Profile { username: username_input.to_string(), age }).
  5. In main, test load_profile with:
    • Valid data (e.g., “Alice”, “30”)
    • Empty username (e.g., “”, “25”)
    • Non-numeric age (e.g., “Bob”, “abc”)
    • Zero age (e.g., “Charlie”, “0”)
// Solution Hint:
/*
use std::error::Error;
use std::fmt;
use std::num::ParseIntError;

struct Profile {
    username: String,
    age: u8,
}

#[derive(Debug)]
enum ProfileError {
    InvalidUsername(String),
    InvalidAge(ParseIntError),
    AgeIsZero, // Added for clarity for age=0 case
    // Io(io::Error), // Optional if you decide to add file I/O later
}

impl fmt::Display for ProfileError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            ProfileError::InvalidUsername(msg) => write!(f, "Invalid username: {}", msg),
            ProfileError::InvalidAge(e) => write!(f, "Invalid age format: {}", e),
            ProfileError::AgeIsZero => write!(f, "Age cannot be zero."),
        }
    }
}

impl Error for ProfileError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            ProfileError::InvalidAge(e) => Some(e),
            _ => None,
        }
    }
}

impl From<ParseIntError> for ProfileError {
    fn from(error: ParseIntError) -> Self {
        ProfileError::InvalidAge(error)
    }
}

fn load_profile(username_input: &str, age_input: &str) -> Result<Profile, ProfileError> {
    if username_input.is_empty() {
        return Err(ProfileError::InvalidUsername(String::from("Username cannot be empty")));
    }

    let age: u8 = age_input.parse()?; // Using `?` here
    if age == 0 {
        return Err(ProfileError::AgeIsZero);
    }

    Ok(Profile {
        username: username_input.to_string(),
        age,
    })
}

fn main() {
    let test_cases = vec![
        ("Alice", "30"),
        ("", "25"),
        ("Bob", "abc"),
        ("Charlie", "0"),
        ("David", "150"), // Valid
    ];

    for (user, age) in test_cases {
        match load_profile(user, age) {
            Ok(profile) => println!("Loaded profile: {} (Age: {})", profile.username, profile.age),
            Err(e) => eprintln!("Failed to load profile for '{}', '{}': {}", user, age, e),
        }
    }
}
*/