On this page
Error handling: Result, Option, and the ? operator
Error handling in Rust
Rust has no exceptions. Instead, it uses the type system to represent operations that can fail: Result<T, E> and Option<T>. This approach forces the programmer to consider error cases explicitly, eliminating a whole category of bugs.
Review: Result and Option
// In the stdlib:
// enum Result<T, E> { Ok(T), Err(E) }
// enum Option<T> { Some(T), None }
fn main() {
// Result for operations that can fail with a specific error
let success: Result<i32, &str> = Ok(42);
let failure: Result<i32, &str> = Err("something went wrong");
// Option for values that may not exist
let present: Option<&str> = Some("hello");
let absent: Option<&str> = None;
// Both are normal enums — handled with match
match success {
Ok(n) => println!("Success: {n}"),
Err(e) => println!("Error: {e}"),
}
match present {
Some(s) => println!("Value: {s}"),
None => println!("No value"),
}
}The `?` operator: elegant error propagation
The ? operator is the most important piece of error handling in Rust. It does two things:
- If the value is
Ok(T), it extractsTand continues - If the value is
Err(E), it converts the error (viaFrom) and returns immediately
use std::fs;
use std::io;
// Without ? — verbose and noisy
fn read_file_verbose(path: &str) -> Result<String, io::Error> {
let content = match fs::read_to_string(path) {
Ok(s) => s,
Err(e) => return Err(e),
};
Ok(content.to_uppercase())
}
// With ? — clean and direct
fn read_file(path: &str) -> Result<String, io::Error> {
let content = fs::read_to_string(path)?;
Ok(content.to_uppercase())
}
// Chaining multiple operations that can fail
fn process_number(s: &str) -> Result<f64, Box<dyn std::error::Error>> {
let n: i32 = s.trim().parse()?; // ParseIntError → Box<dyn Error>
let root = if n >= 0 {
(n as f64).sqrt()
} else {
return Err("Cannot compute square root of negative".into());
};
Ok(root)
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
match process_number(" 16 ") {
Ok(r) => println!("Square root: {r}"),
Err(e) => println!("Error: {e}"),
}
match process_number("abc") {
Ok(r) => println!("Square root: {r}"),
Err(e) => println!("Error: {e}"),
}
Ok(())
}Custom error types
For real projects, you will want to create descriptive error types:
use std::fmt;
use std::num::ParseIntError;
#[derive(Debug)]
enum AppError {
InvalidInput { field: String, message: String },
Database(String),
Network { code: u16, url: String },
Parse(ParseIntError),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::InvalidInput { field, message } => {
write!(f, "Invalid input for '{field}': {message}")
}
AppError::Database(msg) => write!(f, "Database error: {msg}"),
AppError::Network { code, url } => {
write!(f, "Network error {code} connecting to {url}")
}
AppError::Parse(e) => write!(f, "Parse error: {e}"),
}
}
}
// Implement std::error::Error for AppError
impl std::error::Error for AppError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
AppError::Parse(e) => Some(e),
_ => None,
}
}
}
// Automatic conversion from ParseIntError
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> Self {
AppError::Parse(e)
}
}
fn validate_age(s: &str) -> Result<u8, AppError> {
let age: i32 = s.parse()?; // ParseIntError is converted via From
if age < 0 || age > 150 {
return Err(AppError::InvalidInput {
field: String::from("age"),
message: format!("{age} is not a valid age (0-150)"),
});
}
Ok(age as u8)
}
fn main() {
for input in ["25", "abc", "-5", "200"] {
match validate_age(input) {
Ok(age) => println!("Valid age: {age}"),
Err(e) => println!("Error: {e}"),
}
}
}Useful methods on Result and Option
fn main() {
let ok: Result<i32, &str> = Ok(42);
let err: Result<i32, &str> = Err("failure");
// --- Result methods ---
// unwrap: extract value or panic
println!("{}", ok.unwrap());
// expect: like unwrap but with custom message
println!("{}", ok.expect("Should be Ok"));
// unwrap_or: default value if Err
println!("{}", err.unwrap_or(0));
// unwrap_or_else: compute default value
println!("{}", err.unwrap_or_else(|e| { println!("Error: {e}"); -1 }));
// map: transform the Ok value
let doubled = ok.map(|n| n * 2);
println!("{:?}", doubled);
// map_err: transform the error
let transformed = err.map_err(|e| format!("ERROR: {e}"));
println!("{:?}", transformed);
// and_then: chain Results
let result = ok
.and_then(|n| if n > 0 { Ok(n * 10) } else { Err("negative") })
.and_then(|n| Ok(format!("Result: {n}")));
println!("{:?}", result);
// is_ok / is_err
println!("Is ok Ok? {}", ok.is_ok());
println!("Is err Err? {}", err.is_err());
// --- Option methods ---
let some: Option<i32> = Some(10);
let none: Option<i32> = None;
println!("{}", some.unwrap_or(0));
println!("{}", none.unwrap_or_default()); // i32::default() = 0
// ok_or: convert Option to Result
let r: Result<i32, &str> = none.ok_or("no value");
println!("{:?}", r);
// filter: keep Some only if condition is met
let even = some.filter(|n| n % 2 == 0);
println!("{:?}", even); // Some(10) — 10 is even
// zip: combine two Options
let a = Some(1);
let b = Some("hello");
println!("{:?}", a.zip(b)); // Some((1, "hello"))
}Panic: when and when not to use it
fn main() {
// panic! for situations that "should never happen"
// (programming errors, not user errors)
let v = vec![1, 2, 3];
// v[10]; // panic: index out of bounds
// assert! for preconditions
fn calculate_root(n: f64) -> f64 {
assert!(n >= 0.0, "Cannot calculate root of {n}");
n.sqrt()
}
// todo! for not-yet-implemented code
fn pending_function() -> i32 {
todo!("Implement business logic")
}
// unreachable! for branches that should never be reached
let state = 1_u8;
let message = match state {
0 => "inactive",
1 => "active",
2 => "suspended",
_ => unreachable!("Invalid state: {state}"),
};
println!("State: {message}");
println!("{:.4}", calculate_root(16.0));
}With robust error handling in your arsenal, the next lesson explores modules and crates — how Rust organizes code into reusable units.
The ? operator only works in functions returning Result or Option
If you use ? in main(), change its signature to fn main() -> Result<(), Box<dyn std::error::Error>>. This allows propagating errors directly from main and they get printed automatically if they occur.
Avoid unwrap() and expect() in production code
unwrap() and expect() panic if the value is Err or None. They are useful in prototypes, tests, and when you are absolutely certain the value exists. In production code, propagate the error with ? or handle it with match/if let.
use std::num::ParseIntError;
use std::fmt;
// Custom error type
#[derive(Debug)]
enum CalcError {
DivisionByZero,
InvalidNumber(ParseIntError),
NegativeNotAllowed(i64),
}
impl fmt::Display for CalcError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DivisionByZero => write!(f, "Division by zero"),
Self::InvalidNumber(e) => write!(f, "Invalid number: {e}"),
Self::NegativeNotAllowed(n) => {
write!(f, "Negative not allowed: {n}")
}
}
}
}
impl From<ParseIntError> for CalcError {
fn from(e: ParseIntError) -> Self {
CalcError::InvalidNumber(e)
}
}
fn divide(a: i64, b: i64) -> Result<i64, CalcError> {
if b == 0 { return Err(CalcError::DivisionByZero); }
if a < 0 { return Err(CalcError::NegativeNotAllowed(a)); }
Ok(a / b)
}
fn parse_and_divide(a: &str, b: &str) -> Result<i64, CalcError> {
let a: i64 = a.trim().parse()?; // ? converts via From
let b: i64 = b.trim().parse()?;
divide(a, b)
}
fn main() {
let cases = [("100", "4"), ("abc", "2"), ("50", "0"), ("-10", "2")];
for (a, b) in &cases {
match parse_and_divide(a, b) {
Ok(r) => println!("{a}/{b} = {r}"),
Err(e) => println!("Error: {e}"),
}
}
}
Sign in to track your progress