06. Error Handling
📋 Jump to Takeaways🎁 Rust has no exceptions. No try/catch. No null. And somehow it's better at error handling than languages that have all three. Errors are just values — you return them, match on them, and the compiler makes sure you never ignore one by accident.
panic! vs Recoverable Errors
Rust splits errors into two categories: unrecoverable (bugs) and recoverable (expected failures).
// Unrecoverable — program crashes immediately
panic!("something went terribly wrong");
// Recoverable — caller decides what to do
let file = std::fs::read_to_string("config.toml");
// file is Result<String, io::Error>Use panic! for programming errors — index out of bounds, impossible states. Use Result for anything that might fail in normal operation: file I/O, network calls, parsing.
The Result<T, E> Enum
Result is just an enum with two variants:
enum Result<T, E> {
Ok(T), // success, holds the value
Err(E), // failure, holds the error
}Every function that can fail returns a Result. No exceptions flying through your call stack — the error is right there in the return type.
use std::fs;
fn read_config() -> Result<String, std::io::Error> {
fs::read_to_string("config.toml")
}Matching on Result
You handle a Result with pattern matching, just like Option.
match fs::read_to_string("config.toml") {
Ok(contents) => println!("Config: {}", contents),
Err(e) => println!("Failed to read config: {}", e),
}The compiler won't let you use the success value without first checking whether it's actually there. No NullPointerException surprises.
unwrap and expect
When you're prototyping or you know a value must exist, unwrap and expect crash on error.
// Crashes with generic message if Err
let contents = fs::read_to_string("config.toml").unwrap();
// Crashes with YOUR message if Err
let contents = fs::read_to_string("config.toml")
.expect("config.toml must exist in project root");Use expect in tests and setup code where failure means the environment is broken. Avoid unwrap in production code — it gives no context when things go wrong.
The ? Operator
The ? operator propagates errors to the caller. If the value is Ok, it unwraps it. If it's Err, it returns early from the function with that error.
use std::fs;
use std::io;
fn read_username() -> Result<String, io::Error> {
let contents = fs::read_to_string("username.txt")?;
Ok(contents.trim().to_string())
}Without ?, you'd need a match for every fallible call. With it, error propagation is a single character. You can chain multiple ? calls and the function returns the first error encountered.
fn setup() -> Result<String, io::Error> {
let config = fs::read_to_string("config.toml")?;
let db_url = fs::read_to_string("db_url.txt")?;
let secret = fs::read_to_string("secret.key")?;
Ok(format!("{}{}{}", config, db_url, secret))
}Three potential failures, zero nested matches.
Multiple Error Types
Real functions often encounter different error types. You can't return Result<T, io::Error> if one call produces a parse error. Use Box<dyn Error> as a catch-all.
use std::error::Error;
use std::fs;
fn parse_port() -> Result<u16, Box<dyn Error>> {
let contents = fs::read_to_string("port.txt")?; // io::Error
let port = contents.trim().parse::<u16>()?; // ParseIntError
Ok(port)
}Box<dyn Error> accepts any type that implements the Error trait. You lose specific type info, but gain flexibility. This is fine for application code.
Custom Error Types
For libraries, you define your own error enum so callers can match on specific failures.
use std::fmt;
use std::num::ParseIntError;
use std::io;
#[derive(Debug)]
enum AppError {
Io(io::Error),
Parse(ParseIntError),
NotFound(String),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
AppError::Io(e) => write!(f, "IO error: {}", e),
AppError::Parse(e) => write!(f, "Parse error: {}", e),
AppError::NotFound(name) => write!(f, "Not found: {}", name),
}
}
}
impl std::error::Error for AppError {}
// Convert from underlying errors automatically
impl From<io::Error> for AppError {
fn from(e: io::Error) -> Self {
AppError::Io(e)
}
}
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> Self {
AppError::Parse(e)
}
}With From implementations, the ? operator automatically converts errors into your custom type.
fn load_config() -> Result<u16, AppError> {
let contents = fs::read_to_string("port.txt")?; // io::Error → AppError::Io
let port = contents.trim().parse::<u16>()?; // ParseIntError → AppError::Parse
Ok(port)
}Error Propagation Chain
Here's how errors flow through a real application:
fn read_port() -> Result<u16, AppError> {
let text = fs::read_to_string("port.txt")?;
let port = text.trim().parse::<u16>()?;
if port < 1024 {
return Err(AppError::NotFound("port must be >= 1024".into()));
}
Ok(port)
}
fn start_server() -> Result<(), AppError> {
let port = read_port()?;
println!("Starting on port {}", port);
Ok(())
}
fn main() {
match start_server() {
Ok(()) => println!("Server running"),
Err(e) => eprintln!("Failed to start: {}", e),
}
}
// If port.txt is missing: "Failed to start: IO error: No such file or directory"
// If port.txt has "abc": "Failed to start: Parse error: invalid digit found in string"
// If port.txt has "80": "Failed to start: Not found: port must be >= 1024"Each layer adds context. The top-level main makes one decision: print success or print the error.
anyhow and thiserror
Writing all those From impls by hand gets tedious. The ecosystem has two crates that handle it:
thiserror — for libraries. Generates Display and From impls via derive macros.
use thiserror::Error;
#[derive(Debug, Error)]
enum AppError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Parse error: {0}")]
Parse(#[from] std::num::ParseIntError),
#[error("Not found: {0}")]
NotFound(String),
}That's it. All the boilerplate from before, gone.
anyhow — for applications. When you don't need callers to match on specific error types.
use anyhow::{Context, Result};
fn read_port() -> Result<u16> {
let text = std::fs::read_to_string("port.txt")
.context("failed to read port.txt")?;
let port = text.trim().parse::<u16>()
.context("port.txt must contain a valid port number")?;
Ok(port)
}anyhow::Result<T> is shorthand for Result<T, anyhow::Error>. The .context() method adds human-readable messages to the error chain. Use thiserror when you're writing a library others consume. Use anyhow when you're writing an application and just need good error messages.
Key Takeaways
panic!is for bugs;Result<T, E>is for expected failuresResultis an enum —Ok(value)orErr(error)— no exceptions neededunwrap/expectcrash on error — use only in tests or when failure is impossible?propagates errors in one character — returns early onErrBox<dyn Error>accepts any error type — good for quick application code- Custom error enums +
Fromimpls give callers precise control thiserrorgenerates boilerplate for library errorsanyhowsimplifies error handling in applications with.context()
🎁 Next up: collections. You'll learn how Vec<T> grows dynamically without garbage collection, why HashMap is your go-to key-value store, and why String is surprisingly complicated — it's not just an array of characters.