Error Handling with Result and Option
Learn Rust's explicit error handling with Result and Option enums. Master the ? operator, error propagation, and creating custom error types. No more surprise crashes.
Operations fail. Files don't exist. Network connections time out. Parsing invalid data produces errors. Most languages use exceptions—throwing errors that propagate up the call stack until caught or crashing the program. This hides failure modes. Functions appear to return one type but can actually return that type or throw an exception. Reading code doesn't reveal where failures occur or what handling them requires.
Rust makes errors explicit through the type system. Functions that can fail return Result<T, E>—either success with value T or failure with error E. Functions that might lack a value return Option<T>—either Some(T) or None. The compiler ensures you handle both cases. You can't accidentally ignore errors or access values that might not exist. Failure handling is visible in function signatures and enforced at compile time.
Option<T>: Handling Absence
The Option<T> enum represents optional values:
enum Option<T> {
Some(T),
None,
}
Use Option when a value might legitimately be absent. Searching for an item in a list might find nothing. Parsing a string might produce no valid result. Division might be undefined for certain inputs.
fn find_first_even(numbers: &[i32]) -> Option<i32> {
for &num in numbers {
if num % 2 == 0 {
return Some(num);
}
}
None
}
let numbers = vec![1, 3, 5, 8, 11];
match find_first_even(&numbers) {
Some(n) => println!("Found: {}", n),
None => println!("No even numbers"),
}
The function returns Some(i32) when it finds an even number, None otherwise. The caller must handle both cases. The type system prevents using the result as if it's always a value.
unwrap and expect
The unwrap method extracts the value from Some or panics if the value is None:
let x = Some(5);
let value = x.unwrap(); // value is 5
let y: Option<i32> = None;
let value = y.unwrap(); // Panics: "called `Option::unwrap()` on a `None` value"
Panicking terminates the thread. For single-threaded programs, this crashes the program. Use unwrap only when you're absolutely certain the value is Some, or during prototyping when you'll add proper error handling later.
The expect method is similar but lets you provide a custom panic message:
let y: Option<i32> = None;
let value = y.expect("Expected a number"); // Panics with your message
In production code, prefer pattern matching or the methods below over unwrap and expect.
Result<T, E>: Handling Errors
The Result<T, E> enum represents operations that can succeed or fail:
enum Result<T, E> {
Ok(T),
Err(E),
}
Functions that can fail return Result. Success produces Ok(T). Failure produces Err(E) containing error information.
use std::fs::File;
use std::io::Error;
fn open_file(path: &str) -> Result<File, Error> {
File::open(path)
}
match open_file("data.txt") {
Ok(file) => println!("File opened successfully"),
Err(error) => println!("Failed to open file: {}", error),
}
The function returns Result<File, Error>. The caller must handle both success and failure. The type signature makes it obvious the operation can fail.
The ? Operator
Propagating errors through multiple function calls creates boilerplate:
use std::fs::File;
use std::io::{self, Read};
fn read_file() -> Result<String, io::Error> {
let file = File::open("data.txt");
let mut file = match file {
Ok(f) => f,
Err(e) => return Err(e),
};
let mut contents = String::new();
match file.read_to_string(&mut contents) {
Ok(_) => Ok(contents),
Err(e) => Err(e),
}
}
Each operation that returns Result needs a match to either extract the value or return the error early.
The ? operator eliminates this boilerplate:
fn read_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)
}
The ? operator unpacks Ok values and returns Err values early. If File::open returns Err, the error returns from the function immediately. If it returns Ok(file), the value binds to file and execution continues.
The ? operator works only in functions that return Result or Option. You can't use it in main unless main returns Result:
use std::error::Error;
fn main() -> Result<(), Box<dyn Error>> {
let contents = read_file()?;
println!("{}", contents);
Ok(())
}
The type Box<dyn Error> represents any error type. This lets main return any error that implements the Error trait.
Combining Results
Chaining operations that return Result uses ?:
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)
}
Even more concise with method chaining:
fn read_username_from_file() -> Result<String, io::Error> {
let mut username = String::new();
File::open("username.txt")?.read_to_string(&mut username)?;
Ok(username)
}
Or using fs::read_to_string:
use std::fs;
fn read_username_from_file() -> Result<String, io::Error> {
fs::read_to_string("username.txt")
}
Each approach is progressively more concise while maintaining explicit error handling.
Creating Custom Error Types
Define custom error types with enums:
#[derive(Debug)]
enum ParseError {
InvalidFormat,
OutOfRange,
MissingField(String),
}
fn parse_value(input: &str) -> Result<i32, ParseError> {
if input.is_empty() {
return Err(ParseError::InvalidFormat);
}
match input.parse::<i32>() {
Ok(n) if n >= 0 && n <= 100 => Ok(n),
Ok(_) => Err(ParseError::OutOfRange),
Err(_) => Err(ParseError::InvalidFormat),
}
}
The error type ParseError describes specific failure modes. Callers can match on the error variant and respond appropriately.
Option and Result Methods
Option and Result provide many methods for working with values without explicit pattern matching.
The unwrap_or method provides a default value:
let x = Some(5);
let value = x.unwrap_or(0); // value is 5
let y: Option<i32> = None;
let value = y.unwrap_or(0); // value is 0
The unwrap_or_else method computes a default value lazily:
let value = y.unwrap_or_else(|| expensive_computation());
The closure runs only if the value is None.
The map method transforms Some values:
let x = Some(5);
let squared = x.map(|n| n * n); // Some(25)
let y: Option<i32> = None;
let squared = y.map(|n| n * n); // None
The and_then method chains operations that return Option:
fn double(x: i32) -> Option<i32> {
Some(x * 2)
}
let x = Some(5);
let result = x.and_then(double); // Some(10)
let y: Option<i32> = None;
let result = y.and_then(double); // None
These methods work similarly for Result:
let result: Result<i32, String> = Ok(5);
let doubled = result.map(|n| n * 2); // Ok(10)
let error: Result<i32, String> = Err(String::from("error"));
let doubled = error.map(|n| n * 2); // Err("error")
When to Use Option vs Result
Use Option<T> when absence is normal and carries no additional information. A search that finds nothing isn't an error—it's a legitimate outcome.
Use Result<T, E> when failure needs explanation. File operations fail for specific reasons: permissions, missing files, I/O errors. Network operations time out or encounter malformed data. The error type E provides context for why the operation failed.
Panicking for Unrecoverable Errors
Some errors are unrecoverable. A program might encounter a bug—a violated invariant, corrupted state, or a logic error. Panicking terminates the thread and optionally unwinds the stack, running destructors.
The panic! macro triggers a panic:
fn check_invariant(x: i32) {
if x < 0 {
panic!("Invariant violated: x must be non-negative, got {}", x);
}
}
Use panics for bugs, not for expected failures. Invalid user input, missing files, and network errors are expected failures that should return Result. Logic errors, index out of bounds, and violated invariants are bugs that should panic.
The unwrap and expect methods panic when encountering None or Err. Use them when you have a guarantee the value exists, or during prototyping. In production code, handle errors explicitly.
Error Conversion with From
The ? operator automatically converts errors using the From trait:
use std::fs::File;
use std::io;
use std::num::ParseIntError;
#[derive(Debug)]
enum MyError {
Io(io::Error),
Parse(ParseIntError),
}
impl From<io::Error> for MyError {
fn from(err: io::Error) -> MyError {
MyError::Io(err)
}
}
impl From<ParseIntError> for MyError {
fn from(err: ParseIntError) -> MyError {
MyError::Parse(err)
}
}
fn read_number() -> Result<i32, MyError> {
let mut file = File::open("number.txt")?; // io::Error converts to MyError
let mut contents = String::new();
file.read_to_string(&mut contents)?; // io::Error converts to MyError
let number: i32 = contents.trim().parse()?; // ParseIntError converts to MyError
Ok(number)
}
The ? operator calls From::from to convert each error type to MyError. This lets you return a unified error type from functions that call operations with different error types.
The Error Trait
Custom error types should implement the Error trait from the standard library:
use std::fmt;
use std::error::Error;
#[derive(Debug)]
struct MyError {
message: String,
}
impl fmt::Display for MyError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.message)
}
}
impl Error for MyError {}
The Display trait formats the error for user-facing messages. The Error trait marks the type as an error, enabling interoperability with error-handling libraries and making the type usable with Box<dyn Error>.
Why Explicit Error Handling Matters
Exceptions hide failure modes. A function signature shows only the success type, not what errors it might throw. You discover errors by reading documentation, running into runtime crashes, or analyzing the implementation.
Rust's approach makes errors visible in type signatures. A function returning Result<T, E> documents that it can fail. The error type E describes failure modes. The compiler enforces handling both success and failure.
This front-loads error handling. You can't accidentally ignore errors. You can't assume operations succeed without checking. The compiler ensures you address failure at compile time rather than discovering it in production.
The ? operator makes error propagation concise. Rust's error handling feels lightweight despite being explicit. Pattern matching provides full control when you need it. Methods like unwrap_or and map provide convenience when you don't.
No more surprise crashes from uncaught exceptions. No more forgotten null checks. Errors are values you handle explicitly, and the compiler ensures you don't forget.
