Error Handling with Result and Option
/ 5 min read
Table of Contents
Coming from languages with exceptions, Rust’s error handling felt backward at first. But now I love how explicit it makes error cases - no more hidden exceptions that crash your program unexpectedly.
The Core Types
Rust uses two main types for handling “missing” or “failed” operations:
// Option - for values that might not existenum Option<T> { Some(T), None,}
// Result - for operations that might failenum Result<T, E> { Ok(T), // Success case with value Err(E), // Error case with error info}Every function that might fail returns a Result. Every value that might not exist is wrapped in Option.
No More Null Pointer Exceptions
Option replaces null/nil with compiler-enforced checking:
fn find_user(id: u32) -> Option<User> { // Search logic... if id == 42 { Some(User { name: "Alice".to_string(), id }) } else { None }}
// You MUST handle the None casematch find_user(42) { Some(user) => println!("Found: {}", user.name), None => println!("User not found"),}The compiler won’t let you forget to check for the None case.
When I Actually Use Error Types
Most of the time, I work with Results for:
- File and network operations: Things that can fail:
use std::fs;use std::io;
fn read_config_file(path: &str) -> Result<String, io::Error> { fs::read_to_string(path)}
fn load_application() { match read_config_file("app.config") { Ok(content) => { println!("Config loaded: {} chars", content.len()); }, Err(e) => { eprintln!("Failed to load config: {}", e); println!("Using defaults instead"); } }}- Parsing and validation: Converting strings to other types:
fn parse_port(input: &str) -> Result<u16, String> { match input.parse::<u16>() { Ok(port) if port > 0 => Ok(port), Ok(_) => Err("Port must be greater than 0".to_string()), Err(_) => Err(format!("'{}' is not a valid port number", input)), }}
// Usagelet user_input = "8080";match parse_port(user_input) { Ok(port) => println!("Starting server on port {}", port), Err(msg) => eprintln!("Error: {}", msg),}- Database and API calls: Operations that might time out or fail:
#[derive(Debug)]enum DatabaseError { ConnectionFailed, QueryTimeout, InvalidData(String),}
fn fetch_user_data(user_id: u32) -> Result<UserData, DatabaseError> { // Simulate database call if user_id == 0 { return Err(DatabaseError::InvalidData("User ID cannot be 0".to_string())); }
// Simulate network timeout if user_id == 999 { return Err(DatabaseError::QueryTimeout); }
Ok(UserData { id: user_id, name: format!("User {}", user_id) })}- Chain operations with the
?operator: Propagating errors up:
fn process_user_request(user_id: u32) -> Result<String, DatabaseError> { let user_data = fetch_user_data(user_id)?; // Return early if error let processed_name = format!("Processed: {}", user_data.name); Ok(processed_name)}
// The ? operator is equivalent to:// match fetch_user_data(user_id) {// Ok(data) => data,// Err(e) => return Err(e),// }Combining Results
You often need to combine multiple fallible operations:
fn load_and_parse_config() -> Result<Config, Box<dyn std::error::Error>> { let content = fs::read_to_string("config.json")?; let config: Config = serde_json::from_str(&content)?;
// Validate the config if config.port == 0 { return Err("Invalid port in config".into()); }
Ok(config)}
// Or collect multiple results:fn load_multiple_configs() -> Result<Vec<Config>, Box<dyn std::error::Error>> { let paths = vec!["config1.json", "config2.json", "config3.json"];
let configs: Result<Vec<_>, _> = paths .iter() .map(|path| { let content = fs::read_to_string(path)?; let config: Config = serde_json::from_str(&content)?; Ok(config) }) .collect();
configs}Working with Options
Options are perfect for values that might not exist:
struct Database { users: std::collections::HashMap<u32, String>,}
impl Database { fn find_user(&self, id: u32) -> Option<&String> { self.users.get(&id) }
fn find_user_by_name(&self, name: &str) -> Option<u32> { self.users .iter() .find(|(_, user_name)| *user_name == name) .map(|(id, _)| *id) }}
// Chain Option operationsfn get_user_display_name(db: &Database, id: u32) -> String { db.find_user(id) .map(|name| format!("User: {}", name)) .unwrap_or_else(|| "Unknown User".to_string())}Real World Example
Here’s how I handle errors in a web request handler:
use serde::{Deserialize, Serialize};use std::collections::HashMap;
#[derive(Debug)]enum ApiError { InvalidInput(String), DatabaseError(String), NotFound, InternalError,}
#[derive(Deserialize)]struct CreateUserRequest { name: String, email: String,}
#[derive(Serialize)]struct User { id: u32, name: String, email: String,}
struct UserService { users: HashMap<u32, User>, next_id: u32,}
impl UserService { fn create_user(&mut self, request: CreateUserRequest) -> Result<User, ApiError> { // Validation if request.name.trim().is_empty() { return Err(ApiError::InvalidInput("Name cannot be empty".to_string())); }
if !request.email.contains('@') { return Err(ApiError::InvalidInput("Invalid email format".to_string())); }
// Check if user already exists let email_exists = self.users .values() .any(|user| user.email == request.email);
if email_exists { return Err(ApiError::InvalidInput("Email already registered".to_string())); }
// Create user let user = User { id: self.next_id, name: request.name.trim().to_string(), email: request.email.to_lowercase(), };
self.users.insert(self.next_id, user.clone()); self.next_id += 1;
Ok(user) }
fn get_user(&self, id: u32) -> Result<&User, ApiError> { self.users.get(&id).ok_or(ApiError::NotFound) }
fn update_user(&mut self, id: u32, name: Option<String>, email: Option<String>) -> Result<&User, ApiError> { let user = self.users.get_mut(&id).ok_or(ApiError::NotFound)?;
if let Some(new_name) = name { if new_name.trim().is_empty() { return Err(ApiError::InvalidInput("Name cannot be empty".to_string())); } user.name = new_name.trim().to_string(); }
if let Some(new_email) = email { if !new_email.contains('@') { return Err(ApiError::InvalidInput("Invalid email format".to_string())); } user.email = new_email.to_lowercase(); }
Ok(user) }}
// API handlerfn handle_create_user(service: &mut UserService, request: CreateUserRequest) -> String { match service.create_user(request) { Ok(user) => format!("Created user: {} (ID: {})", user.name, user.id), Err(ApiError::InvalidInput(msg)) => format!("Bad request: {}", msg), Err(ApiError::DatabaseError(msg)) => format!("Database error: {}", msg), Err(ApiError::NotFound) => "Not found".to_string(), Err(ApiError::InternalError) => "Internal server error".to_string(), }}Useful Patterns
Some patterns I use constantly:
Early Return with ?
fn complex_operation() -> Result<String, Box<dyn std::error::Error>> { let data = read_file("input.txt")?; let parsed = parse_data(&data)?; let processed = process_data(parsed)?; let result = format_output(processed)?; Ok(result)}Default Values
// For Optionlet name = user.name.unwrap_or_else(|| "Anonymous".to_string());
// For Resultlet config = load_config().unwrap_or_else(|_| Config::default());Converting Between Types
// Option to Resultlet result: Result<User, &str> = maybe_user.ok_or("User not found");
// Result to Optionlet maybe_data: Option<String> = risky_operation().ok();The Pattern I Follow
I use Result when:
- Operations can fail with specific error information
- I need to propagate errors up the call stack
- I want to handle different error types differently
I use Option when:
- Values might simply not exist
- I’m doing lookups that might not find anything
- I want to chain operations that might fail
The key insight is making errors part of the type system. You can’t ignore them accidentally, and the compiler helps ensure you handle every case. It takes some getting used to, but it leads to code that’s much harder to break.