Rust's Error Handling Philosophy
Rust has no exceptions. Instead, it uses two mechanisms: Result<T, E> for
recoverable errors and panic! for unrecoverable errors. This makes error handling
explicit — you always know which functions can fail and are forced to handle errors.
When to Use What
- Result<T, E>: File not found, network timeout, parse error — expected failures that callers should handle
- panic!: Index out of bounds, broken invariants, unrecoverable state — bugs that shouldn't happen
panic! — Unrecoverable Errors
fn main() {
// Explicit panic
// panic!("Something went terribly wrong!");
// Panics from the standard library
let v = vec![1, 2, 3];
// v[99]; // panic: index out of bounds
// unwrap() panics if Result/Option is Err/None
// let f = std::fs::read_to_string("nonexistent.txt").unwrap();
// expect() panics with a custom message
// let f = std::fs::read_to_string("config.toml")
// .expect("Failed to read config file");
// Set RUST_BACKTRACE=1 to see the full backtrace on panic
println!("This runs fine");
}
Result<T, E> — Recoverable Errors
use std::fs;
use std::io;
fn read_username_from_file() -> Result {
let result = fs::read_to_string("username.txt");
match result {
Ok(contents) => Ok(contents.trim().to_string()),
Err(error) => match error.kind() {
io::ErrorKind::NotFound => {
// Create a default file
fs::write("username.txt", "default_user")?;
Ok(String::from("default_user"))
}
other_error => Err(io::Error::new(other_error, "Read failed")),
},
}
}
fn main() {
match read_username_from_file() {
Ok(name) => println!("Username: {name}"),
Err(e) => eprintln!("Error: {e}"),
}
}
The ? Operator
The ? operator is syntactic sugar for propagating errors. If the Result is Ok,
it unwraps the value. If Err, it returns the error from the current function.
use std::fs;
use std::io;
// Verbose: manual matching
fn read_file_verbose(path: &str) -> Result {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(e) => return Err(e),
};
Ok(content)
}
// Concise: with ? operator
fn read_file_concise(path: &str) -> Result {
let content = fs::read_to_string(path)?;
Ok(content)
}
// Even shorter — chain ? calls
fn read_first_line(path: &str) -> Result {
Ok(fs::read_to_string(path)?
.lines()
.next()
.unwrap_or("")
.to_string())
}
// ? works with Option too!
fn first_even(numbers: &[i32]) -> Option {
let first = numbers.first()?; // Returns None if empty
if first % 2 == 0 {
Some(*first)
} else {
None
}
}
fn main() -> Result<(), Box> {
// main() can return Result too!
let content = fs::read_to_string("Cargo.toml")?;
println!("First 50 chars: {}", &content[..50.min(content.len())]);
Ok(())
}
Custom Error Types
use std::fmt;
use std::num::ParseIntError;
#[derive(Debug)]
enum AppError {
IoError(std::io::Error),
ParseError(ParseIntError),
ValidationError(String),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::IoError(e) => write!(f, "IO error: {e}"),
AppError::ParseError(e) => write!(f, "Parse error: {e}"),
AppError::ValidationError(msg) => write!(f, "Validation: {msg}"),
}
}
}
impl std::error::Error for AppError {}
// From conversions for automatic ? conversion
impl From for AppError {
fn from(e: std::io::Error) -> Self {
AppError::IoError(e)
}
}
impl From for AppError {
fn from(e: ParseIntError) -> Self {
AppError::ParseError(e)
}
}
fn process_file(path: &str) -> Result {
let content = std::fs::read_to_string(path)?; // io::Error -> AppError
let number: i32 = content.trim().parse()?; // ParseIntError -> AppError
if number < 0 {
return Err(AppError::ValidationError("Must be positive".into()));
}
Ok(number)
}
thiserror and anyhow Crates
// thiserror: for LIBRARY code (structured errors)
// Cargo.toml: thiserror = "2"
use thiserror::Error;
#[derive(Error, Debug)]
enum DataError {
#[error("Failed to read file: {0}")]
Io(#[from] std::io::Error),
#[error("Failed to parse data: {0}")]
Parse(#[from] serde_json::Error),
#[error("Validation failed: {field} — {message}")]
Validation { field: String, message: String },
#[error("Record not found: id={0}")]
NotFound(u64),
}
// anyhow: for APPLICATION code (quick & flexible)
// Cargo.toml: anyhow = "1"
use anyhow::{Context, Result, bail, ensure};
fn load_config(path: &str) -> Result {
let content = std::fs::read_to_string(path)
.context("Failed to read config file")?;
let config: Config = serde_json::from_str(&content)
.context("Failed to parse config JSON")?;
ensure!(config.port > 0, "Port must be positive, got {}", config.port);
if config.name.is_empty() {
bail!("Config name cannot be empty");
}
Ok(config)
}
struct Config { port: i32, name: String }
Key Takeaways
- ✅ Use
Result<T, E>for recoverable errors,panic!for bugs - ✅ The
?operator propagates errors concisely up the call stack - ✅ Implement
Fromtraits for automatic error conversion with? - ✅ Use
thiserrorfor structured library errors,anyhowfor application errors - ✅ Always prefer returning errors over panicking in library code