Rust - Error Handling (Result, Option, panic)
Overview
Estimated time: 60–75 minutes
Master Rust's approach to error handling without exceptions using Result<T,E>
and Option<T>
, understand when to use panic!
, and learn error propagation patterns that make Rust code both safe and expressive.
Learning Objectives
- Use
Option<T>
to handle the absence of values safely - Handle errors with
Result<T,E>
and pattern matching - Propagate errors using the
?
operator effectively - Understand when to use
panic!
vs recoverable errors - Create custom error types and implement error traits
Prerequisites
Option<T> - Handling Absence of Values
Rust doesn't have null values. Instead, it uses Option<T>
to represent values that might be absent:
// Option is defined as:
enum Option<T> {
Some(T),
None,
}
fn main() {
let some_number = Some(5);
let some_string = Some("hello");
let absent_number: Option<i32> = None;
println!("some_number: {:?}", some_number);
println!("absent_number: {:?}", absent_number);
}
Expected output:
some_number: Some(5)
absent_number: None
Working with Option Values
fn divide(numerator: f64, denominator: f64) -> Option<f64> {
if denominator == 0.0 {
None
} else {
Some(numerator / denominator)
}
}
fn main() {
let result = divide(10.0, 3.0);
match result {
Some(value) => println!("Result: {}", value),
None => println!("Cannot divide by zero!"),
}
// Using if let for simpler cases
if let Some(value) = divide(20.0, 4.0) {
println!("Division result: {}", value);
}
// Using unwrap_or for default values
let safe_result = divide(15.0, 0.0).unwrap_or(0.0);
println!("Safe result: {}", safe_result);
}
Expected output:
Result: 3.3333333333333335
Division result: 5
Safe result: 0
Result<T,E> - Recoverable Error Handling
Result<T,E>
represents operations that can succeed or fail:
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let file_result = File::open("hello.txt");
let file = match file_result {
Ok(file) => file,
Err(error) => match error.kind() {
ErrorKind::NotFound => {
println!("File not found, creating a new one...");
match File::create("hello.txt") {
Ok(fc) => fc,
Err(e) => panic!("Problem creating file: {:?}", e),
}
}
other_error => {
panic!("Problem opening file: {:?}", other_error)
}
}
};
println!("File handled successfully");
}
Custom Result Functions
#[derive(Debug)]
enum MathError {
DivisionByZero,
NegativeSquareRoot,
}
fn safe_divide(a: f64, b: f64) -> Result<f64, MathError> {
if b == 0.0 {
Err(MathError::DivisionByZero)
} else {
Ok(a / b)
}
}
fn sqrt(x: f64) -> Result<f64, MathError> {
if x < 0.0 {
Err(MathError::NegativeSquareRoot)
} else {
Ok(x.sqrt())
}
}
fn main() {
// Successful operations
match safe_divide(10.0, 2.0) {
Ok(result) => println!("10 / 2 = {}", result),
Err(e) => println!("Error: {:?}", e),
}
// Error cases
match safe_divide(10.0, 0.0) {
Ok(result) => println!("Result: {}", result),
Err(e) => println!("Division error: {:?}", e),
}
match sqrt(-4.0) {
Ok(result) => println!("Square root: {}", result),
Err(e) => println!("Math error: {:?}", e),
}
}
Expected output:
10 / 2 = 5
Division error: DivisionByZero
Math error: NegativeSquareRoot
Error Propagation with the ? Operator
The ?
operator simplifies error propagation:
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let mut file = File::open("username.txt")?;
let mut username = String::new();
file.read_to_string(&mut username)?;
Ok(username)
}
// Equivalent to the above without ? operator:
fn read_username_verbose() -> Result<String, io::Error> {
let file_result = File::open("username.txt");
let mut file = match file_result {
Ok(file) => file,
Err(e) => return Err(e),
};
let mut username = String::new();
match file.read_to_string(&mut username) {
Ok(_) => Ok(username),
Err(e) => Err(e),
}
}
fn main() {
match read_username_from_file() {
Ok(username) => println!("Username: {}", username.trim()),
Err(e) => println!("Failed to read username: {}", e),
}
}
Chaining Operations with ?
fn calculate_complex(a: f64, b: f64, c: f64) -> Result<f64, MathError> {
let division_result = safe_divide(a, b)?;
let sqrt_result = sqrt(division_result)?;
safe_divide(sqrt_result, c)
}
fn main() {
match calculate_complex(100.0, 4.0, 2.0) {
Ok(result) => println!("Complex calculation: {}", result),
Err(e) => println!("Calculation failed: {:?}", e),
}
// This will fail at the division by zero
match calculate_complex(100.0, 0.0, 2.0) {
Ok(result) => println!("Result: {}", result),
Err(e) => println!("Error occurred: {:?}", e),
}
}
Expected output:
Complex calculation: 2.5
Error occurred: DivisionByZero
When to Use panic!
panic!
should be used for unrecoverable errors where the program cannot continue:
fn main() {
// Use panic! for programming errors
let v = vec![1, 2, 3];
// This would panic with index out of bounds
// v[99]; // Don't do this!
// Better: use get() which returns Option
match v.get(99) {
Some(value) => println!("Value: {}", value),
None => println!("Index out of bounds"),
}
// Explicit panic for unrecoverable situations
let user_input = "not_a_number";
let parsed: Result<i32, _> = user_input.parse();
match parsed {
Ok(num) => println!("Parsed number: {}", num),
Err(_) => {
println!("Invalid input, cannot continue");
// In a real app, you might want to prompt again instead
// panic!("Critical error: invalid user input");
}
}
}
Unwrap and Expect
fn main() {
let some_value = Some(42);
let no_value: Option<i32> = None;
// unwrap() panics if None
let value = some_value.unwrap();
println!("Unwrapped value: {}", value);
// expect() panics with a custom message
let file = File::open("config.txt")
.expect("Failed to open config.txt - make sure it exists");
// Safer alternatives
let safe_value = no_value.unwrap_or(0);
println!("Safe value: {}", safe_value);
let computed_value = no_value.unwrap_or_else(|| {
println!("Computing default value...");
42
});
println!("Computed value: {}", computed_value);
}
Custom Error Types
use std::fmt;
#[derive(Debug)]
enum CalculatorError {
DivisionByZero,
InvalidOperation(String),
ParseError(String),
}
impl fmt::Display for CalculatorError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Self::DivisionByZero => write!(f, "Cannot divide by zero"),
Self::InvalidOperation(op) => write!(f, "Invalid operation: {}", op),
Self::ParseError(input) => write!(f, "Cannot parse '{}' as number", input),
}
}
}
impl std::error::Error for CalculatorError {}
fn calculate(expression: &str) -> Result<f64, CalculatorError> {
let parts: Vec<&str> = expression.split_whitespace().collect();
if parts.len() != 3 {
return Err(CalculatorError::InvalidOperation(expression.to_string()));
}
let a: f64 = parts[0].parse()
.map_err(|_| CalculatorError::ParseError(parts[0].to_string()))?;
let operator = parts[1];
let b: f64 = parts[2].parse()
.map_err(|_| CalculatorError::ParseError(parts[2].to_string()))?;
match operator {
"+" => Ok(a + b),
"-" => Ok(a - b),
"*" => Ok(a * b),
"/" => {
if b == 0.0 {
Err(CalculatorError::DivisionByZero)
} else {
Ok(a / b)
}
}
_ => Err(CalculatorError::InvalidOperation(operator.to_string())),
}
}
fn main() {
let expressions = vec![
"10 + 5",
"20 / 4",
"15 / 0",
"not_a_number + 5",
"10 % 3", // Invalid operator
];
for expr in expressions {
match calculate(expr) {
Ok(result) => println!("{} = {}", expr, result),
Err(e) => println!("Error in '{}': {}", expr, e),
}
}
}
Expected output:
10 + 5 = 15
20 / 4 = 5
Error in '15 / 0': Cannot divide by zero
Error in 'not_a_number + 5': Cannot parse 'not_a_number' as number
Error in '10 % 3': Invalid operation: %
Common Pitfalls
1. Overusing unwrap()
// Avoid this - can cause panic
let value = some_option.unwrap();
// Better - handle the None case
let value = match some_option {
Some(v) => v,
None => return, // or handle appropriately
};
// Or use unwrap_or for defaults
let value = some_option.unwrap_or(default_value);
2. Not Using the ? Operator
// Verbose and error-prone
fn process_file() -> Result<String, io::Error> {
let file = match File::open("data.txt") {
Ok(f) => f,
Err(e) => return Err(e),
};
// ... more match statements
}
// Clean and idiomatic
fn process_file() -> Result<String, io::Error> {
let mut file = File::open("data.txt")?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents)
}
3. Using panic! for Recoverable Errors
// Avoid - terminates the program
fn divide(a: f64, b: f64) -> f64 {
if b == 0.0 {
panic!("Division by zero!");
}
a / b
}
// Better - return a Result
fn divide(a: f64, b: f64) -> Result<f64, &'static str> {
if b == 0.0 {
Err("Division by zero")
} else {
Ok(a / b)
}
}
Best Practices
- Use Result for recoverable errors - File not found, network issues, invalid input
- Use Option for optional values - Search results, configuration values
- Use panic! sparingly - Only for programming errors and unrecoverable situations
- Prefer ? operator - Makes error propagation clean and readable
- Create custom error types - For better error information and handling
- Use expect() with descriptive messages - When you're confident an operation should succeed
Checks for Understanding
Question 1: Option vs Result
When would you use Option<T>
vs Result<T,E>
?
Answer
Use Option<T>
when a value might be absent but there's no error condition (like searching in a list). Use Result<T,E>
when an operation can fail and you need to know why it failed (like file operations or parsing).
Question 2: Error Propagation
What does the ?
operator do and when can you use it?
Answer
The ?
operator unwraps successful values and automatically returns errors. It can only be used in functions that return Result
or Option
, and the error types must be compatible.
Question 3: Custom Errors
What traits should a custom error type implement?
Answer
Custom error types should implement Debug
, Display
, and std::error::Error
. This makes them compatible with Rust's error handling ecosystem and provides good error messages.