Error handling in Rust isn’t an afterthought. It’s part of the foundation. When I first started, I was used to languages where problems popped up unexpectedly at runtime. In Rust, the design encourages you to think about what could go wrong, right from the start. This shift in mindset is what leads to robust software. Let me share some practical ways I’ve learned to work with errors effectively.
The language gives us two main tools for this: Result and Option. They are simple enums that force you to acknowledge that a value might be missing or an operation might fail. You can’t accidentally use a failed Result as if it succeeded. The compiler won’t allow it. This guide walks through several patterns that turn this compile-time checking from a constraint into a superpower for writing clear and reliable code.
The question mark operator, ?, is often the first pattern you’ll love. It takes a Result or Option, and if it’s a success (Ok or Some), it gives you the value inside. If it’s a failure (Err or None), it returns that failure from the current function immediately. This keeps your main logic clean.
use std::fs::File;
use std::io::{self, Read};
fn read_small_file(path: &str) -> io::Result<String> {
let mut file = File::open(path)?; // If open fails, return the error now.
let mut contents = String::new();
file.read_to_string(&mut contents)?; // If reading fails, return that error.
Ok(contents) // Only reached if everything worked.
}
This code reads like a simple sequence of steps. The error handling is there, but it doesn’t obscure the intent. I remember writing similar code in other languages with cascading if statements checking for nulls or exceptions. This is much neater.
When your code grows, you need your own error types. A generic io::Error or String isn’t enough. Callers need to know what specific problem occurred so they can handle it appropriately. You can build your own error enum. The thiserror crate makes this straightforward by automating the boilerplate.
use thiserror::Error;
#[derive(Error, Debug)]
pub enum DatabaseError {
#[error("Could not connect to the server at {0}:{1}")]
ConnectionFailed(String, u16),
#[error("Query '{0}' timed out after {1} seconds")]
Timeout(String, u32),
#[error("User '{0}' does not have permission for this action")]
PermissionDenied(String),
}
fn run_user_query(user: &str, query: &str) -> Result<(), DatabaseError> {
if user != "admin" {
return Err(DatabaseError::PermissionDenied(user.to_string()));
}
// ... try to connect and run query ...
Ok(())
}
Now, a function that returns a DatabaseError tells a clear story. Anyone calling it can match on the variants to decide whether to retry a connection, log a timeout, or alert about permissions. It becomes a documented part of your interface.
For application development, where you’re stitching together many libraries, you sometimes need a different approach. Defining a custom error enum for every module can be heavy. Often, you just want to add a helpful message as an error travels up the call stack. This is where anyhow is useful.
use anyhow::{Context, Result};
fn start_application() -> Result<()> {
let config_path = "app_config.toml";
let config_text = std::fs::read_to_string(config_path)
.context(format!("Could not find config file at {}", config_path))?;
let port: u16 = std::env::var("APP_PORT")
.context("APP_PORT environment variable must be set")?
.parse()
.context("APP_PORT must be a valid number")?;
println!("Starting on port {}", port);
Ok(())
}
The context method attaches a message to an error. If the file read fails, the final error will say “Could not find config file at app_config.toml” along with the underlying OS error. It’s like leaving breadcrumbs, making debugging much simpler. I use anyhow::Result in application binaries for this reason.
You will often work with functions that return an error type different from yours. The map_err method is your adapter. It transforms an error as it passes through, allowing you to convert a lower-level error into one of your domain-specific variants.
#[derive(Debug)]
enum AppError {
BadInput(String),
ServiceUnavailable,
}
fn configure_worker(count_str: &str) -> Result<u32, AppError> {
// `parse` returns a std::num::ParseIntError.
// We map it to our `AppError::BadInput`.
let count: u32 = count_str.parse()
.map_err(|_| AppError::BadInput(count_str.to_string()))?;
if count > 100 {
return Err(AppError::BadInput("Value too large".to_string()));
}
Ok(count)
}
This keeps your error types clean and focused. The caller deals only with AppError, not a mix of parsing, network, and file errors.
Some operations can fail for a variety of distinct reasons. Modeling this with an enum is powerful. It gives the caller all the information needed for precise handling. Let’s say we’re validating a form submission.
enum FormError {
MissingField(&'static str),
EmailInvalid,
AgeOutsideRange { min: u8, max: u8, provided: u8 },
}
fn validate_submission(name: &str, email: &str, age: u8) -> Result<(), FormError> {
if name.is_empty() {
return Err(FormError::MissingField("name"));
}
if !email.contains('@') {
return Err(FormError::EmailInvalid);
}
if !(18..=120).contains(&age) {
return Err(FormError::AgeOutsideRange { min: 18, max: 120, provided: age });
}
Ok(())
}
When you call this function, you can offer specific feedback: “The name field is required” versus “You must be between 18 and 120 years old.” This is far better than a single, vague “Invalid input” error.
Frequently, you have an Option and its None case is actually an error for your context. The ok_or and ok_or_else methods bridge this gap by turning an Option<T> into a Result<T, E>.
let user_id = 42;
let user_list = vec!["alice", "bob", "charlie"];
// `ok_or` takes a concrete error value.
let name = user_list.get(user_id as usize)
.ok_or("User index out of bounds")?;
// `ok_or_else` takes a closure, useful for expensive error construction.
let config_file = std::env::var("CONFIG_PATH")
.ok()
.ok_or_else(|| {
let default_path = String::from("/etc/app/default.conf");
eprintln!("Warning: Using default config at {}", default_path);
std::io::Error::new(std::io::ErrorKind::NotFound, "Config not set")
})?;
I use ok_or_else when creating the error involves allocation or logging. The closure only runs if we actually have a None, avoiding unnecessary work on the success path.
What if you’re processing a list of items and want to know about all the failures, not just the first one? You can collect results into a Result<Vec<T>, E> or even design a function that returns all errors found.
fn batch_convert(strings: Vec<&str>) -> Result<Vec<f64>, Vec<String>> {
let mut numbers = Vec::new();
let mut failures = Vec::new();
for s in strings {
match s.parse::<f64>() {
Ok(n) => numbers.push(n),
Err(_) => failures.push(format!("Could not parse '{}' as a number", s)),
}
}
if failures.is_empty() {
Ok(numbers)
} else {
Err(failures)
}
}
// Usage
let result = batch_convert(vec!["1.2", "abc", "3.4", "def"]);
match result {
Ok(nums) => println!("All good: {:?}", nums),
Err(errs) => println!("Problems: {:?}", errs), // Shows both errors.
}
This is excellent for batch jobs or validation suites. You give the user the complete picture, not just the first stumble.
Finally, a fundamental pattern: make failure part of your type’s API. Don’t provide a plain constructor that can create an invalid object. Instead, offer a constructor that returns a Result.
struct AccountBalance {
amount_cents: i64, // Can be negative for overdraft, but has a limit.
}
impl AccountBalance {
fn new(initial_deposit_cents: i64) -> Result<Self, &'static str> {
const MIN_BALANCE: i64 = -10_000; // $100 overdraft limit.
if initial_deposit_cents < MIN_BALANCE {
Err("Initial deposit below minimum allowed balance.")
} else {
Ok(AccountBalance { amount_cents: initial_deposit_cents })
}
}
}
// This guarantees any `AccountBalance` instance you hold is already validated.
let balance = AccountBalance::new(-5_000)?; // Ok.
let invalid = AccountBalance::new(-15_000); // This is an Err.
If new succeeds, you know the invariant holds. This pattern prevents invalid states from spreading through your system. It pushes the problem to the edge, where it can be dealt with properly.
Each of these patterns tackles a different aspect of handling the unexpected. They help you move from simply avoiding crashes to designing interfaces that communicate failure clearly. The goal isn’t to eliminate errors—that’s impossible—but to handle them in a way that makes your software predictable and easier to maintain. By making error cases explicit in your types, Rust gives you the tools to do just that.