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
- Use the
?
operator for concise error propagation. - Understand how
?
works withResult
andOption
. - Convert between different error types during propagation.
- Chain operations that can fail using
?
. - Handle error propagation in different contexts.
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 ?
- Use
?
for error propagation: When you want to pass errors up the call stack - Use explicit matching: When you need to handle specific error cases
- Use
map_err
: When you need to transform or add context to errors - Use
Box<dyn Error>
: For functions that can return different error types
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
- Using
?
without proper error types: Ensure error conversion is available - Losing error context: Add meaningful context when propagating
- Over-using
unwrap()
: Use?
for propagation instead - Ignoring error conversion: Implement
From
trait for custom errors
Checks for Understanding
- What does the
?
operator do when applied to aResult
? - How does
?
work withOption
types? - What trait enables automatic error type conversion with
?
? - When should you use
map_err
with?
?
Answers
- It unwraps
Ok
values and early-returnsErr
values from the function - It unwraps
Some
values and early-returnsNone
from functions returningOption
- The
From
trait enables automatic conversion between error types - Use
map_err
to add context or transform errors before propagation