Rust - Error Propagation

Overview

Estimated time: 35–45 minutes

Master error propagation in Rust using the ? operator and other techniques. Learn how to efficiently handle and propagate errors up the call stack while maintaining clean, readable code.

Learning Objectives

Prerequisites

The ? Operator

Basic Error Propagation

The ? operator provides a concise way to propagate errors:

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

// Without ? operator (verbose)
fn read_file_verbose(path: &str) -> Result {
    let mut file = match File::open(path) {
        Ok(file) => file,
        Err(error) => return Err(error),
    };
    
    let mut contents = String::new();
    match file.read_to_string(&mut contents) {
        Ok(_) => Ok(contents),
        Err(error) => Err(error),
    }
}

// With ? operator (concise)
fn read_file_concise(path: &str) -> Result {
    let mut file = File::open(path)?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    Ok(contents)
}

// Even more concise
fn read_file_chain(path: &str) -> Result {
    std::fs::read_to_string(path)
}

fn main() {
    match read_file_concise("example.txt") {
        Ok(contents) => println!("File contents: {}", contents),
        Err(error) => println!("Error reading file: {}", error),
    }
}

How ? Works

Understanding the mechanics of the ? operator:

// The ? operator is syntactic sugar for this pattern:
fn example_without_question_mark() -> Result {
    let result = might_fail();
    let value = match result {
        Ok(val) => val,
        Err(err) => return Err(err),
    };
    
    // Continue with value...
    Ok(value * 2)
}

// Equivalent using ?
fn example_with_question_mark() -> Result {
    let value = might_fail()?;
    Ok(value * 2)
}

fn might_fail() -> Result {
    if rand::random() {
        Ok(42)
    } else {
        Err("Something went wrong".to_string())
    }
}

fn main() {
    match example_with_question_mark() {
        Ok(result) => println!("Success: {}", result),
        Err(error) => println!("Error: {}", error),
    }
}

Error Propagation Patterns

Chaining Operations

Chain multiple operations that can fail:

use std::fs::File;
use std::io::{self, BufRead, BufReader};

fn process_number_file(path: &str) -> Result> {
    let file = File::open(path)?;
    let reader = BufReader::new(file);
    
    let mut sum = 0;
    for line in reader.lines() {
        let line = line?;                    // IO error
        let number: i32 = line.trim().parse()?;  // Parse error
        sum += number;
    }
    
    Ok(sum)
}

fn calculate_average(numbers: &[&str]) -> Result> {
    let mut sum = 0.0;
    let mut count = 0;
    
    for num_str in numbers {
        let num: f64 = num_str.parse()?;
        sum += num;
        count += 1;
    }
    
    if count == 0 {
        return Err("No numbers provided".into());
    }
    
    Ok(sum / count as f64)
}

fn main() {
    // Chaining file operations
    match process_number_file("numbers.txt") {
        Ok(sum) => println!("Sum: {}", sum),
        Err(error) => println!("Error processing file: {}", error),
    }
    
    // Chaining parse operations
    let numbers = vec!["10.5", "20.3", "30.1"];
    match calculate_average(&numbers) {
        Ok(avg) => println!("Average: {:.2}", avg),
        Err(error) => println!("Error calculating average: {}", error),
    }
}

Early Returns with ?

Use ? for early returns in complex functions:

use std::collections::HashMap;

#[derive(Debug)]
struct User {
    id: u32,
    name: String,
    email: String,
}

#[derive(Debug)]
struct Database {
    users: HashMap,
}

impl Database {
    fn new() -> Self {
        let mut users = HashMap::new();
        users.insert(1, User {
            id: 1,
            name: "Alice".to_string(),
            email: "[email protected]".to_string(),
        });
        users.insert(2, User {
            id: 2,
            name: "Bob".to_string(),
            email: "[email protected]".to_string(),
        });
        
        Database { users }
    }
    
    fn get_user(&self, id: u32) -> Option<&User> {
        self.users.get(&id)
    }
}

fn validate_email(email: &str) -> Result<(), String> {
    if email.contains('@') && email.contains('.') {
        Ok(())
    } else {
        Err("Invalid email format".to_string())
    }
}

fn get_user_info(db: &Database, user_id: u32) -> Result {
    let user = db.get_user(user_id).ok_or("User not found")?;
    validate_email(&user.email)?;
    
    // If we get here, all validations passed
    Ok(format!("User: {} ({})", user.name, user.email))
}

fn complex_user_operation(db: &Database, user_id: u32) -> Result {
    // Multiple operations that can fail
    let user = db.get_user(user_id).ok_or("User not found")?;
    validate_email(&user.email)?;
    
    // More complex logic...
    if user.name.is_empty() {
        return Err("User name is empty".to_string());
    }
    
    if user.id == 0 {
        return Err("Invalid user ID".to_string());
    }
    
    Ok(format!("Processed user: {}", user.name))
}

fn main() {
    let db = Database::new();
    
    // Successful case
    match get_user_info(&db, 1) {
        Ok(info) => println!("Success: {}", info),
        Err(error) => println!("Error: {}", error),
    }
    
    // Error case - user not found
    match get_user_info(&db, 999) {
        Ok(info) => println!("Success: {}", info),
        Err(error) => println!("Error: {}", error),
    }
}

Working with Option

? with Option Types

The ? operator also works with Option:

fn get_first_word(text: &str) -> Option<&str> {
    let first_line = text.lines().next()?;
    let first_word = first_line.split_whitespace().next()?;
    Some(first_word)
}

fn parse_coordinates(input: &str) -> Option<(i32, i32)> {
    let parts: Vec<&str> = input.split(',').collect();
    if parts.len() != 2 {
        return None;
    }
    
    let x = parts[0].trim().parse().ok()?;
    let y = parts[1].trim().parse().ok()?;
    Some((x, y))
}

fn find_user_email(users: &[(&str, &str)], name: &str) -> Option<&str> {
    let user = users.iter().find(|(n, _)| *n == name)?;
    Some(user.1)
}

fn main() {
    let text = "Hello world\nThis is a test";
    match get_first_word(text) {
        Some(word) => println!("First word: {}", word),
        None => println!("No words found"),
    }
    
    let coord_input = "10, 20";
    match parse_coordinates(coord_input) {
        Some((x, y)) => println!("Coordinates: ({}, {})", x, y),
        None => println!("Invalid coordinates"),
    }
    
    let users = vec![("Alice", "[email protected]"), ("Bob", "[email protected]")];
    match find_user_email(&users, "Alice") {
        Some(email) => println!("Alice's email: {}", email),
        None => println!("User not found"),
    }
}

Error Type Conversion

From Trait for Error Conversion

The ? operator automatically converts errors using the From trait:

use std::fs::File;
use std::io;
use std::num::ParseIntError;

// Custom error type
#[derive(Debug)]
enum MyError {
    Io(io::Error),
    Parse(ParseIntError),
    Custom(String),
}

// Implement From for automatic conversion
impl From for MyError {
    fn from(error: io::Error) -> Self {
        MyError::Io(error)
    }
}

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

impl From<&str> for MyError {
    fn from(error: &str) -> Self {
        MyError::Custom(error.to_string())
    }
}

fn read_and_parse_number(path: &str) -> Result {
    let content = std::fs::read_to_string(path)?;  // io::Error -> MyError
    let number: i32 = content.trim().parse()?;     // ParseIntError -> MyError
    
    if number < 0 {
        return Err("Number must be positive".into()); // &str -> MyError
    }
    
    Ok(number)
}

// Using Box for flexibility
fn flexible_error_handling(path: &str) -> Result> {
    let content = std::fs::read_to_string(path)?;
    let number: i32 = content.trim().parse()?;
    
    if number == 0 {
        return Err("Zero is not allowed".into());
    }
    
    Ok(number * 2)
}

fn main() {
    match read_and_parse_number("number.txt") {
        Ok(num) => println!("Number: {}", num),
        Err(MyError::Io(e)) => println!("IO Error: {}", e),
        Err(MyError::Parse(e)) => println!("Parse Error: {}", e),
        Err(MyError::Custom(e)) => println!("Custom Error: {}", e),
    }
    
    match flexible_error_handling("number.txt") {
        Ok(result) => println!("Result: {}", result),
        Err(error) => println!("Error: {}", error),
    }
}

Advanced Error Propagation

Combining Result and Option

Handle mixed Result and Option scenarios:

use std::collections::HashMap;

struct Config {
    settings: HashMap,
}

impl Config {
    fn new() -> Self {
        let mut settings = HashMap::new();
        settings.insert("host".to_string(), "localhost".to_string());
        settings.insert("port".to_string(), "8080".to_string());
        settings.insert("timeout".to_string(), "30".to_string());
        
        Config { settings }
    }
    
    fn get(&self, key: &str) -> Option<&String> {
        self.settings.get(key)
    }
}

fn get_port_number(config: &Config) -> Result {
    let port_str = config.get("port").ok_or("Port not configured")?;
    let port: u16 = port_str.parse().map_err(|_| "Invalid port number")?;
    
    if port == 0 {
        return Err("Port cannot be zero".to_string());
    }
    
    Ok(port)
}

fn get_timeout_seconds(config: &Config) -> Result {
    let timeout_str = config.get("timeout").ok_or("Timeout not configured")?;
    let timeout: u64 = timeout_str.parse().map_err(|_| "Invalid timeout value")?;
    Ok(timeout)
}

fn create_server_config(config: &Config) -> Result<(String, u16, u64), String> {
    let host = config.get("host").ok_or("Host not configured")?.clone();
    let port = get_port_number(config)?;
    let timeout = get_timeout_seconds(config)?;
    
    Ok((host, port, timeout))
}

fn main() {
    let config = Config::new();
    
    match create_server_config(&config) {
        Ok((host, port, timeout)) => {
            println!("Server config: {}:{} (timeout: {}s)", host, port, timeout);
        }
        Err(error) => println!("Configuration error: {}", error),
    }
}

Error Context and Chaining

Add context to errors during propagation:

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

fn read_config_file(path: &str) -> Result {
    std::fs::read_to_string(path)
        .map_err(|e| format!("Failed to read config file '{}': {}", path, e))
}

fn parse_config(content: &str) -> Result, String> {
    let mut config = HashMap::new();
    
    for (line_num, line) in content.lines().enumerate() {
        if line.trim().is_empty() || line.starts_with('#') {
            continue;
        }
        
        let parts: Vec<&str> = line.splitn(2, '=').collect();
        if parts.len() != 2 {
            return Err(format!("Invalid config format at line {}: '{}'", line_num + 1, line));
        }
        
        let key = parts[0].trim().to_string();
        let value = parts[1].trim().to_string();
        
        if key.is_empty() {
            return Err(format!("Empty key at line {}", line_num + 1));
        }
        
        config.insert(key, value);
    }
    
    Ok(config)
}

fn load_and_parse_config(path: &str) -> Result, String> {
    let content = read_config_file(path)
        .map_err(|e| format!("Configuration loading failed: {}", e))?;
    
    parse_config(&content)
        .map_err(|e| format!("Configuration parsing failed: {}", e))
}

fn main() {
    match load_and_parse_config("app.conf") {
        Ok(config) => {
            println!("Loaded configuration:");
            for (key, value) in config {
                println!("  {} = {}", key, value);
            }
        }
        Err(error) => println!("Error: {}", error),
    }
}

Best Practices

When to Use ?

Error Propagation Guidelines

// Good: Early return with context
fn process_user_data(user_id: u32) -> Result {
    let user = fetch_user(user_id)
        .map_err(|e| format!("Failed to fetch user {}: {}", user_id, e))?;
    
    let profile = fetch_profile(user.id)
        .map_err(|e| format!("Failed to fetch profile for user {}: {}", user.id, e))?;
    
    Ok(format!("User: {} - {}", user.name, profile.bio))
}

// Avoid: Nested matching
fn process_user_data_bad(user_id: u32) -> Result {
    match fetch_user(user_id) {
        Ok(user) => {
            match fetch_profile(user.id) {
                Ok(profile) => Ok(format!("User: {} - {}", user.name, profile.bio)),
                Err(e) => Err(format!("Profile error: {}", e)),
            }
        }
        Err(e) => Err(format!("User error: {}", e)),
    }
}

fn fetch_user(id: u32) -> Result {
    // Implementation...
    Ok(User { id, name: "Test".to_string() })
}

fn fetch_profile(user_id: u32) -> Result {
    // Implementation...
    Ok(Profile { bio: "Test bio".to_string() })
}

struct User { id: u32, name: String }
struct Profile { bio: String }

Common Pitfalls

Mistakes to Avoid

Checks for Understanding

  1. What does the ? operator do when applied to a Result?
  2. How does ? work with Option types?
  3. What trait enables automatic error type conversion with ??
  4. When should you use map_err with ??
Answers
  1. It unwraps Ok values and early-returns Err values from the function
  2. It unwraps Some values and early-returns None from functions returning Option
  3. The From trait enables automatic conversion between error types
  4. Use map_err to add context or transform errors before propagation

← PreviousNext →