Intermediate Topics: Error Handling with Result and Option
Rust’s approach to error handling is one of its most celebrated features, prioritizing explicitness and compile-time guarantees over runtime exceptions. This chapter will deepen your understanding of how Rust manages errors using the Result<T, E> and Option<T> enums, and how to effectively use the ? operator for error propagation.
Recoverable vs. Unrecoverable Errors
Rust categorizes errors into two main types:
Recoverable Errors: These are problems that are likely to happen, but that you can and should respond to. Examples include file not found, incorrect user input, or network connection issues. Rust handles these with the
Result<T, E>enum.Unrecoverable Errors: These are typically bugs in your code that put the program in an invalid state, where there’s no way to proceed safely. Examples include out-of-bounds array access, impossible conditions, or unexpected
Nonevalues where aSomewas guaranteed. Rust handles these by panicking.
panic! for Unrecoverable Errors
When a program panic!s, it will unwind the stack, clean up memory, and then exit. This is a deliberate choice to ensure safety when an invariant is violated.
fn main() {
// This will cause a panic!
// panic!("Crash and burn!");
let v = vec![1, 2, 3];
// v[100]; // This would also panic at runtime due to out-of-bounds access
}
You should use panic! sparingly, typically for cases that indicate a fundamental bug in your logic or an unrecoverable situation. For example, if you assume a value must be present, but it turns out to be None, a panic! can immediately flag this programming error.
unwrap() and expect()
Convenience methods like unwrap() and expect() are available on Option and Result. They extract the inner value if it’s Some or Ok, but panic! if it’s None or Err.
unwrap(): Panics with a default message ifNoneorErr.expect("custom message"): Panics with your custom message ifNoneorErr. This is generally preferred overunwrap()because the custom message makes debugging easier.
fn main() {
let some_value = Some(5);
let value = some_value.unwrap(); // `value` is 5
println!("Unwrapped value: {}", value);
let no_value: Option<i32> = None;
// let value = no_value.unwrap(); // PANICS!
let result_ok: Result<String, &str> = Ok(String::from("Success!"));
let message = result_ok.expect("Should have been an Ok variant!");
println!("Result message: {}", message);
let result_err: Result<String, &str> = Err("File not found");
// let message = result_err.expect("Should have been an Ok variant!"); // PANICS with "Should have been an Ok variant!"
}
Use unwrap() and expect() only when you are absolutely certain that the Option will be Some or the Result will be Ok, or in test code where a panic is acceptable for indicating a test failure.
Result<T, E> for Recoverable Errors
The Result enum is the primary tool for handling errors that your program can potentially recover from. Its definition is:
enum Result<T, E> {
Ok(T), // T is the type of the successful value
Err(E), // E is the type of the error value
}
When a function might fail, it should return Result<T, E>. The calling code is then forced by the compiler to consider both the Ok and Err cases.
Using match with Result
The match expression is the most explicit way to handle Result.
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let file_result = File::open("hello.txt"); // Returns a Result<File, std::io::Error>
match file_result {
Ok(file) => {
println!("File opened successfully: {:?}", file);
}
Err(error) => match error.kind() { // Nested match for specific error kinds
ErrorKind::NotFound => {
println!("File not found, creating it...");
match File::create("hello.txt") {
Ok(new_file) => println!("File created: {:?}", new_file),
Err(e) => eprintln!("Problem creating file: {:?}", e),
}
}
other_error => {
eprintln!("Problem opening file: {:?}", other_error);
}
},
}
}
This demonstrates how you can robustly handle different types of I/O errors.
The ? Operator for Error Propagation
The ? operator is syntactic sugar that simplifies error propagation from functions that return Result. It acts as an early return for Err values.
When you use ? on a Result:
- If the
ResultisOk(value), thevalueis extracted and the function continues. - If the
ResultisErr(error), theerroris returned immediately from the current function.
Crucially, the function where you use ? must return a Result (or Option for Option propagation) where the Err type is compatible with the error type being propagated (or convertible via the From trait).
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let mut f = File::open("username.txt")?; // If Err, returns Err from this function
let mut username = String::new();
f.read_to_string(&mut username)?; // If Err, returns Err from this function
Ok(username) // If both succeed, return Ok with the username
}
fn main() {
match read_username_from_file() {
Ok(username) => println!("Username: {}", username),
Err(e) => eprintln!("Error reading username: {}", e),
}
// Create a dummy file for testing success
if let Err(e) = File::create("username.txt").and_then(|mut f| f.write_all(b"alice")) {
eprintln!("Failed to prepare username.txt: {}", e);
}
match read_username_from_file() {
Ok(username) => println!("Username: {}", username),
Err(e) => eprintln!("Error reading username: {}", e),
}
}
Without ?, the read_username_from_file function would be much more verbose, requiring nested match statements.
Chaining with Result methods
Result also has useful methods for chaining operations:
.ok(): ConvertsResult<T, E>toOption<T>, discarding the error ifErr..err(): ConvertsResult<T, E>toOption<E>, discarding the value ifOk..map(|value| ...): Transforms theOkvalue if present, otherwise propagatesErr..map_err(|error| ...): Transforms theErrvalue if present, otherwise propagatesOk..and_then(|value| -> Result<U, E> { ... }): Chains operations that both returnResult. If the first isOk, the closure is run; otherwise,Erris propagated..or_else(|error| -> Result<T, F> { ... }): Chains operations that both returnResult. If the first isErr, the closure is run; otherwise,Okis propagated.
fn parse_and_double(s: &str) -> Result<i32, String> {
s.parse::<i32>()
.map_err(|e| format!("Failed to parse: {}", e)) // Convert ParseIntError to String
.and_then(|num| { // Only runs if parsing was Ok
if num > 1000 {
Err(String::from("Number is too large!"))
} else {
Ok(num * 2)
}
})
}
fn main() {
println!("{:?}", parse_and_double("10")); // Ok(20)
println!("{:?}", parse_and_double("abc")); // Err("Failed to parse: invalid digit found in string")
println!("{:?}", parse_and_double("2000")); // Err("Number is too large!")
}
Custom Error Types
For application-specific errors, it’s good practice to define your own custom error types. This improves clarity and allows for more precise error handling.
Often, you’ll create an enum for your custom errors and implement the std::fmt::Display and std::error::Error traits on it. The thiserror and anyhow crates are very popular for simplifying custom error creation and propagation.
use std::fmt;
use std::num::ParseIntError; // To include a parsing error
use std::error::Error; // The standard error trait
#[derive(Debug)] // Needed for {:?} formatting
enum AppError {
Io(io::Error), // Wrap std::io::Error
Parse(ParseIntError), // Wrap std::num::ParseIntError
Config(String), // Custom configuration error
}
// Implement Display trait for user-friendly error messages
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
AppError::Io(e) => write!(f, "I/O error: {}", e),
AppError::Parse(e) => write!(f, "Parsing error: {}", e),
AppError::Config(msg) => write!(f, "Configuration error: {}", msg),
}
}
}
// Implement the Error trait for error chaining and integration
impl Error for AppError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
AppError::Io(e) => Some(e),
AppError::Parse(e) => Some(e),
_ => None, // Config error doesn't have an underlying source
}
}
}
// Implement `From` trait for automatic conversion with `?`
impl From<io::Error> for AppError {
fn from(error: io::Error) -> Self {
AppError::Io(error)
}
}
impl From<ParseIntError> for AppError {
fn from(error: ParseIntError) -> Self {
AppError::Parse(error)
}
}
// A function that might return our custom error type
fn process_config_file(path: &str) -> Result<i32, AppError> {
let contents = std::fs::read_to_string(path)?; // `?` will convert io::Error to AppError::Io
let value: i32 = contents.trim().parse()?; // `?` will convert ParseIntError to AppError::Parse
if value < 0 {
return Err(AppError::Config(String::from("Value cannot be negative")));
}
Ok(value)
}
fn main() {
// Test with a non-existent file
match process_config_file("non_existent.txt") {
Ok(value) => println!("Config value: {}", value),
Err(e) => eprintln!("Application error: {}", e),
}
// Create a dummy file for testing valid input
if let Err(e) = File::create("valid_config.txt").and_then(|mut f| f.write_all(b"123\n")) {
eprintln!("Failed to prepare valid_config.txt: {}", e);
}
match process_config_file("valid_config.txt") {
Ok(value) => println!("Config value: {}", value),
Err(e) => eprintln!("Application error: {}", e),
}
// Test with invalid content
if let Err(e) = File::create("invalid_content.txt").and_then(|mut f| f.write_all(b"abc\n")) {
eprintln!("Failed to prepare invalid_content.txt: {}", e);
}
match process_config_file("invalid_content.txt") {
Ok(value) => println!("Config value: {}", value),
Err(e) => eprintln!("Application error: {}", e),
}
// Test with negative value
if let Err(e) = File::create("negative_value.txt").and_then(|mut f| f.write_all(b"-5\n")) {
eprintln!("Failed to prepare negative_value.txt: {}", e);
}
match process_config_file("negative_value.txt") {
Ok(value) => println!("Config value: {}", value),
Err(e) => eprintln!("Application error: {}", e),
}
}
By implementing From for our custom error, we enable seamless error conversion with the ? operator. This makes your error handling code clean and type-safe.
Exercises / Mini-Challenges
Exercise 7.1: Safe Division Function
Write a function safe_divide(numerator: f64, denominator: f64) -> Option<f64> that performs division.
- If the
denominatoris 0, it should returnNone. - Otherwise, it should return
Some(result).
In main, call this function with various inputs (including denominator = 0.0) and use if let to print the result or an appropriate message.
Instructions:
- Define the
safe_dividefunction. - In
main, testsafe_divide(10.0, 2.0),safe_divide(7.0, 0.0), andsafe_divide(100.0, 4.0). - For each call, use
if letto display the result or “Division by zero is not allowed.”
// Solution Hint:
/*
fn safe_divide(numerator: f64, denominator: f64) -> Option<f64> {
if denominator == 0.0 {
None
} else {
Some(numerator / denominator)
}
}
fn main() {
let result1 = safe_divide(10.0, 2.0);
if let Some(val) = result1 {
println!("10.0 / 2.0 = {}", val);
} else {
println!("Division by zero is not allowed.");
}
// Test other cases
}
*/
Exercise 7.2: File Word Counter with Error Handling
Write a function count_words_in_file(filename: &str) -> Result<usize, io::Error> that reads a file and returns the total number of words.
- Use
std::fs::read_to_stringto read the file. - Handle potential
io::Errorusing the?operator. - If the file is read successfully, count the words (split by whitespace) and return
Ok(word_count).
In main, call this function with both an existing file (create one first, e.g., test.txt) and a non-existent file, printing appropriate success or error messages.
Instructions:
- Create a
test.txtfile in your project directory with some content (e.g., “Hello Rust. This is a test file.”). - Define
count_words_in_file. - Inside
count_words_in_file:- Read the file content using
read_to_string(). - If successful, split the content by whitespace and count the resulting elements.
- Read the file content using
- In
main, callcount_words_in_file("test.txt")andcount_words_in_file("non_existent.txt"). - Use a
matchexpression for each call to handleOk(print word count) andErr(print error message).
// Solution Hint:
/*
use std::fs;
use std::io;
fn count_words_in_file(filename: &str) -> Result<usize, io::Error> {
let contents = fs::read_to_string(filename)?; // ? for io::Error propagation
let word_count = contents.split_whitespace().count();
Ok(word_count)
}
fn main() {
// Make sure 'test.txt' exists with content
if let Err(e) = fs::write("test.txt", "Rust is an amazing programming language.") {
eprintln!("Failed to write test.txt: {}", e);
}
match count_words_in_file("test.txt") {
Ok(count) => println!("'test.txt' has {} words.", count),
Err(e) => eprintln!("Error counting words in 'test.txt': {}", e),
}
match count_words_in_file("non_existent.txt") {
Ok(count) => println!("'non_existent.txt' has {} words.", count), // This won't run
Err(e) => eprintln!("Error counting words in 'non_existent.txt': {}", e),
}
}
*/
Exercise 7.3: User Profile Loader with Custom Errors
Create a simple user profile system. Define a Profile struct with username: String and age: u8.
Implement a function load_profile(username_input: &str, age_input: &str) -> Result<Profile, ProfileError> where ProfileError is a custom enum you define.
ProfileError should have the following variants:
InvalidUsername(String): If username is empty.InvalidAge(ParseIntError): If age parsing fails or age is 0.Io(io::Error): If file operations fail (you can optionally load from a file later, but for this exercise, you can skip file interaction and just simulate the errors).
Instructions:
- Define the
Profilestruct andProfileErrorenum. - Implement
std::fmt::Displayandstd::error::ErrorforProfileError. - Implement
From<ParseIntError>forProfileError. - Define
load_profile:- Check if
username_inputis empty. If so, returnErr(ProfileError::InvalidUsername). - Parse
age_inputtou8. Use?for propagation. If the resulting age is 0, returnErr(ProfileError::InvalidAge)(you’ll need to manually check for age 0 after parsing). - If all checks pass, return
Ok(Profile { username: username_input.to_string(), age }).
- Check if
- In
main, testload_profilewith:- Valid data (e.g., “Alice”, “30”)
- Empty username (e.g., “”, “25”)
- Non-numeric age (e.g., “Bob”, “abc”)
- Zero age (e.g., “Charlie”, “0”)
// Solution Hint:
/*
use std::error::Error;
use std::fmt;
use std::num::ParseIntError;
struct Profile {
username: String,
age: u8,
}
#[derive(Debug)]
enum ProfileError {
InvalidUsername(String),
InvalidAge(ParseIntError),
AgeIsZero, // Added for clarity for age=0 case
// Io(io::Error), // Optional if you decide to add file I/O later
}
impl fmt::Display for ProfileError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ProfileError::InvalidUsername(msg) => write!(f, "Invalid username: {}", msg),
ProfileError::InvalidAge(e) => write!(f, "Invalid age format: {}", e),
ProfileError::AgeIsZero => write!(f, "Age cannot be zero."),
}
}
}
impl Error for ProfileError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
ProfileError::InvalidAge(e) => Some(e),
_ => None,
}
}
}
impl From<ParseIntError> for ProfileError {
fn from(error: ParseIntError) -> Self {
ProfileError::InvalidAge(error)
}
}
fn load_profile(username_input: &str, age_input: &str) -> Result<Profile, ProfileError> {
if username_input.is_empty() {
return Err(ProfileError::InvalidUsername(String::from("Username cannot be empty")));
}
let age: u8 = age_input.parse()?; // Using `?` here
if age == 0 {
return Err(ProfileError::AgeIsZero);
}
Ok(Profile {
username: username_input.to_string(),
age,
})
}
fn main() {
let test_cases = vec![
("Alice", "30"),
("", "25"),
("Bob", "abc"),
("Charlie", "0"),
("David", "150"), // Valid
];
for (user, age) in test_cases {
match load_profile(user, age) {
Ok(profile) => println!("Loaded profile: {} (Age: {})", profile.username, profile.age),
Err(e) => eprintln!("Failed to load profile for '{}', '{}': {}", user, age, e),
}
}
}
*/