Rust Error Handling Explained â Result, Option, Panic & Custom Error Types
In this tutorial, you'll learn about Rust Error Handling Explained. We cover key concepts, practical examples, and best practices.
Master Rust error handling: Result and Option combinators, the propagation operator, custom error types with thiserror and anyhow, panic hooks, and production error strategies.
What You'll Learn
In this tutorial, you'll master Rust error handling beyond the basics â chaining combinators like map, and_then, or_else, building custom error types with thiserror, using anyhow for application code, setting panic hooks, and designing error strategies for libraries vs binaries.
Why It Matters
Error handling is not just about catching failures â it shapes your API's usability. Libraries with well-designed error types give callers precise control. Applications with good error context reduce debugging time. Rust's type system makes error handling explicit, but choosing the right pattern for your use case separates production-grade code from prototypes.
Real-World Use
The reqwest HTTP library exposes a rich error type hierarchy with variants for timeouts, redirects, and decode failures. The sqlx database library uses anyhow for query errors with context. Durga Antivirus Pro's scan pipeline uses a custom error enum with typed variants for file I/O, permission denied, signature corruption, and scan timeout.
flowchart TD
A[Operation] --> B{Result or Option?}
B -->|Some value| C[Ok / Some]
B -->|No value at all| D[None]
B -->|Failed with error| E[Err]
C --> F[Continue processing]
D --> G[Default / next option]
E --> H{Propagate or handle?}
H -->|Propagate| I[? operator]
H -->|Handle| J[match / combinator]
Prerequisites: Error Handling basics and Structs & Enums. Understanding traits and generics helps.
Combinators: map, and_then, or_else
Combinators let you chain operations on Result and Option without manual matching:
use std::num::ParseIntError;
fn parse_and_process(input: &str) -> Result<i32, ParseIntError> {
input
.parse::<i32>()
.map(|n| n * 2)
.map_err(|_| ParseIntError { .. })
}
fn safe_divide(a: f64, b: f64) -> Option<f64> {
if b == 0.0 {
None
} else {
Some(a / b)
}
}
fn main() {
let result = parse_and_process("21");
println!("Parsed and doubled: {:?}", result);
let division = safe_divide(10.0, 3.0)
.map(|v| format!("{:.2}", v))
.or_else(|| Some("undefined".to_string()));
println!("Division: {}", division.unwrap());
// Chaining with and_then
let chain = safe_divide(10.0, 2.0)
.and_then(|v| safe_divide(v, 2.0))
.and_then(|v| safe_divide(v, 1.25));
println!("Chained division: {:?}", chain);
}
Expected output:
Parsed and doubled: Ok(42)
Division: 3.33
Chained division: Some(4.0)
Custom Error Types with thiserror
For libraries, define custom error enums with thiserror for automatic Display and Error trait implementations:
use thiserror::Error;
use std::io;
#[derive(Error, Debug)]
pub enum ScanError {
#[error("I/O error reading file: {0}")]
Io(#[from] io::Error),
#[error("Invalid signature format at offset {offset}")]
InvalidSignature { offset: usize },
#[error("Scan timeout after {0}ms")]
Timeout(u64),
#[error("Permission denied: {0}")]
Permission(String),
}
fn scan_file(path: &str) -> Result<Vec<String>, ScanError> {
let data = std::fs::read(path)?; // Auto-converts io::Error via #[from]
if data.is_empty() {
return Err(ScanError::InvalidSignature { offset: 0 });
}
Ok(vec!["clean".to_string()])
}
fn main() {
match scan_file("/etc/hosts") {
Ok(results) => println!("Results: {:?}", results),
Err(e) => println!("Scan error: {}", e),
}
match scan_file("/nonexistent") {
Ok(results) => println!("Results: {:?}", results),
Err(e) => println!("Scan error: {}", e),
}
}
Expected output:
Results: ["clean"]
Scan error: I/O error reading file: No such file or directory (os error 2)
thiserror vs anyhow: Use thiserror in libraries for typed errors that callers can match on. Use anyhow in binaries and application code for quick error handling with context.
Panic Hooks and Unrecoverable Errors
Custom panic hooks let you log or recover from panics gracefully:
use std::panic;
fn main() {
// Install a custom panic hook
panic::set_hook(Box::new(|panic_info| {
let location = panic_info.location()
.map(|l| format!("{}:{}", l.file(), l.line()))
.unwrap_or_else(|| "unknown".into());
let message = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
s.clone()
} else {
"Unknown panic".to_string()
};
eprintln!("[CUSTOM HOOK] Panic at {}: {}", location, message);
}));
// Example: safe array access with explicit panic
fn get_element(vec: &[i32], idx: usize) -> i32 {
if idx >= vec.len() {
panic!("Index {} out of bounds for len {}", idx, vec.len());
}
vec[idx]
}
println!("First: {}", get_element(&[10, 20, 30], 0));
// Use std::panic::catch_unwind to recover
let result = panic::catch_unwind(|| {
get_element(&[10, 20, 30], 10)
});
match result {
Ok(val) => println!("Got: {}", val),
Err(_) => println!("Recovered from panic gracefully"),
}
println!("Program continues after caught panic");
}
Expected output:
First: 10
[CUSTOM HOOK] Panic at src/main.rs:26: Index 10 out of bounds for len 3
Recovered from panic gracefully
Program continues after caught panic
Error Handling Patterns in Production
Patterns for real codebases: typed errors for libraries, anyhow for apps, backtrace support:
// Pattern 1: Box<dyn Error> for heterogeneous errors
fn read_config(path: &str) -> Result<String, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?;
if content.is_empty() {
return Err("config file is empty".into());
}
Ok(content)
}
// Pattern 2: Result<T, E> with crate-internal error type
#[derive(Debug)]
struct AppError(String);
impl std::fmt::Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "AppError: {}", self.0)
}
}
impl std::error::Error for AppError {}
fn validate_input(input: &str) -> Result<(), AppError> {
if input.trim().is_empty() {
Err(AppError("input cannot be blank".into()))
} else if input.len() > 100 {
Err(AppError("input exceeds 100 characters".into()))
} else {
Ok(())
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = read_config("Cargo.toml")?;
println!("Config length: {}", config.len());
validate_input("hello")?;
println!("Input is valid");
let result = validate_input("");
match result {
Err(e) => println!("Validation failed: {}", e),
Ok(_) => println!("OK"),
}
Ok(())
}
Expected output:
Config length: ... (varies)
Input is valid
Validation failed: AppError: input cannot be blank
Common Errors
1. Ignoring Result with unused Ok
let _ = File::create("test.txt"); // Warning: unused Result
File::create("test.txt")?; // Correct: propagate error
2. Overly broad error type
fn parse() -> Result<i32, Box<dyn Error>> { /* too broad for library */ }
3. Forgetting to implement Display for error enums
#[derive(Debug)]
enum MyError { Io(io::Error) }
// ERROR: does not implement Display, cannot use ?
4. Swallowing errors with ok()
let _ = fs::read_to_string("file").ok(); // Silently ignores error
5. panic in library code
fn divide(a: i32, b: i32) -> i32 {
if b == 0 { panic!("division by zero"); } // Bad library pattern
a / b
}
// Better: return Result<i32, DivisionError>
Practice Questions
1. What is the difference between thiserror and anyhow?
thiserror generates custom error types with Display and Error impls â use in libraries. anyhow provides anyhow::Error with context methods like .context() â use in binaries.
2. When should you use catch_unwind?
Use catch_unwind at thread boundaries (e.g., web server request handlers) to prevent a single panic from crashing the entire process. Do NOT use it for control flow â that's what Result is for.
3. How do you convert between different error types with ?
The ? operator uses Into<E>::into() or From<E>::from() to convert errors. Derive #[from] with thiserror or implement From manually.
4. What is the difference between Ok(()) and Ok()?
Ok(()) returns a Result with an empty unit value (success with no data). Ok() without the unit would be a curried function, not a Result. Always use Ok(()).
5. Challenge: Build a configuration file parser that reads key=value pairs and returns a HashMap<String, String>. Define a ConfigError enum with variants for Io, Parse, and DuplicateKey errors. Use thiserror for the error type.
Mini Project: Result-Optimized File Scanner
use std::fs;
use std::io;
use std::path::Path;
#[derive(Debug)]
enum Scanned {
Clean(String),
Infected(String, Vec<String>),
}
fn scan_directory(path: &Path, patterns: &[&str]) -> Result<Vec<Scanned>, io::Error> {
let mut results = Vec::new();
for entry in fs::read_dir(path)? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
match fs::read_to_string(&path) {
Ok(content) => {
let hits: Vec<String> = patterns
.iter()
.filter(|p| content.contains(*p))
.map(|p| p.to_string())
.collect();
if hits.is_empty() {
results.push(Scanned::Clean(path.to_string_lossy().into()));
} else {
results.push(Scanned::Infected(path.to_string_lossy().into(), hits));
}
}
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => {
// Log and skip, don't fail the entire scan
eprintln!("Skipping {}: permission denied", path.display());
}
Err(e) => return Err(e),
}
}
}
Ok(results)
}
fn main() -> Result<(), io::Error> {
let results = scan_directory(
Path::new("/home/admin1/projects/website/dodatech-tutorials"),
&["rust", "unsafe", "std"],
)?;
for r in &results {
match r {
Scanned::Clean(name) => println!("CLEAN: {}", name),
Scanned::Infected(name, hits) => println!("ALERT: {} hit on {:?}", name, hits),
}
}
Ok(())
}
Expected output: (varies by directory contents)
CLEAN: ... (files without matches)
ALERT: ... (files containing "rust", "unsafe", "std")
FAQ
Related Concepts
What's Next
Validate your error handling patterns with Testing in Rust, or see how errors interact with Concurrency & Async in multithreaded contexts.
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro