skip to content
Mehdi Mehdikhani
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 exist
enum Option<T> {
Some(T),
None,
}
// Result - for operations that might fail
enum 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 case
match 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:

  1. 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");
}
}
}
  1. 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)),
}
}
// Usage
let user_input = "8080";
match parse_port(user_input) {
Ok(port) => println!("Starting server on port {}", port),
Err(msg) => eprintln!("Error: {}", msg),
}
  1. 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)
})
}
  1. 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 operations
fn 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 handler
fn 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 Option
let name = user.name.unwrap_or_else(|| "Anonymous".to_string());
// For Result
let config = load_config().unwrap_or_else(|_| Config::default());

Converting Between Types

// Option to Result
let result: Result<User, &str> = maybe_user.ok_or("User not found");
// Result to Option
let 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.