Result and Option: Rust-Style Error Handling
1. Overview
Rust doesn’t have exceptions like Java or C#. Instead, Rust uses two data types, Option<T> and Result<T, E>, to represent “there might not be a value” and “this might fail”. This article helps you understand how to use these two types correctly, how to propagate errors cleanly using the ? operator, and how to define custom errors with thiserror. By the end, you’ll be writing Rust code that handles errors clearly, without needing try/catch.
2. Why Not Use Exceptions?
In Java, a method can throw a RuntimeException at any time without the signature declaring it. You have to read the Javadoc or the code to know what it might throw.
Rust forces you to declare the possibility of failure right in the return type:
fn divide(a: i32, b: i32) -> Result<i32, String> {
if b == 0 {
return Err("division by zero".to_string());
}
Ok(a / b)
}
Looking at the signature Result<i32, String> tells you immediately that this function can fail. The compiler forces you to handle both Ok and Err branches; you can’t silently ignore an error like forgetting a catch in Java.
Note: Rust still has
panic!for unrecoverable errors (programming bugs, invariant violations).Resultis for errors that are predictable and can be handled.
3. Option: Values That May Be Missing
Option<T> replaces null. It has two variants: Some(T) and None.
fn find_customer(id: u32) -> Option<String> {
let customers = vec![(1, "Alice"), (2, "Bob")];
customers.iter()
.find(|(cid, _)| *cid == id)
.map(|(_, name)| name.to_string())
}
fn main() {
match find_customer(2) {
Some(name) => println!("Found: {name}"),
None => println!("No such customer"),
}
}
find returns Option<&(u32, &str)>, and map transforms the value inside Some if present, or leaves None unchanged. The result:
Found: Bob
3.1. Common Methods
let maybe_age: Option<u32> = Some(25);
let age = maybe_age.unwrap_or(18); // 25, or 18 if None
let doubled = maybe_age.map(|a| a * 2); // Some(50)
let result: Result<u32, &str> = maybe_age.ok_or("age missing"); // Ok(25)
unwrap_or(default): get the value or return a default; never panics.map(f): transform the value inside if present, keepNoneunchanged.ok_or(err): convertOption<T>toResult<T, E>— useful when you need to use the?operator later.
Note: Avoid calling
.unwrap()directly onOption/Resultin production code. It panics immediately if you encounterNone/Err, equivalent to throwing an uncontrolledNullPointerException. Only use it in tests or when you’re 100% sure the value exists.
4. Result<T, E>: Success or Failure
Result<T, E> has two variants: Ok(T) for success, Err(E) for failure.
use std::num::ParseIntError;
fn parse_quantity(input: &str) -> Result<u32, ParseIntError> {
input.trim().parse::<u32>()
}
fn main() {
match parse_quantity(" 42 ") {
Ok(qty) => println!("Quantity: {qty}"),
Err(e) => println!("Parse error: {e}"),
}
}
parse::<u32>() returns Result<u32, ParseIntError>. match forces you to handle both branches — the compiler refuses to compile if you omit either one.
Quantity: 42
4.1. The ? Operator for Error Propagation
Writing match everywhere is verbose. The ? operator helps you propagate an error immediately if you encounter Err, similar to automatic throw:
use std::num::ParseIntError;
struct Order {
quantity: u32,
price: u32,
}
fn parse_order(qty_str: &str, price_str: &str) -> Result<Order, ParseIntError> {
let quantity = qty_str.trim().parse::<u32>()?;
let price = price_str.trim().parse::<u32>()?;
Ok(Order { quantity, price })
}
fn main() {
match parse_order("3", "15000") {
Ok(order) => println!("Total: {}", order.quantity * order.price),
Err(e) => println!("Invalid data: {e}"),
}
}
The line qty_str.trim().parse::<u32>()? means: if parse returns Err, the parse_order function returns immediately with that Err to the caller, without needing to write match manually. If Ok, the value inside is “unwrapped” and assigned to quantity.
Total: 45000
Note: The
?operator only works when the error type of the current function and the called function are compatible (or have aFromimplementation for conversion). Otherwise, the compiler reports a type mismatch at build time.
5. Combining Option and Result
These two types often interweave. For example, finding a product in inventory and then checking if stock is sufficient:
struct Product {
name: String,
stock: u32,
}
fn find_product(name: &str, products: &[Product]) -> Option<&Product> {
products.iter().find(|p| p.name == name)
}
fn check_stock(name: &str, requested: u32, products: &[Product]) -> Result<(), String> {
let product = find_product(name, products)
.ok_or_else(|| format!("Product not found: {name}"))?;
if product.stock < requested {
return Err(format!("Only {} items left", product.stock));
}
Ok(())
}
ok_or_else converts Option<&Product> to Result<&Product, String> with an error message, then ? propagates if it’s None. This style avoids deeply nested match expressions.
6. Custom Error Types with thiserror
Using String as the error type is convenient for small examples, but real-world projects need structured error enums. The thiserror crate makes this clean:
<!-- Cargo.toml equivalent, use dependencies section -->
# Cargo.toml
[dependencies]
thiserror = "1.0"
use thiserror::Error;
#[derive(Error, Debug)]
enum OrderError {
#[error("product not found: {0}")]
ProductNotFound(String),
#[error("insufficient stock, {available} items left")]
InsufficientStock { available: u32 },
}
fn validate_order(stock: Option<u32>, requested: u32) -> Result<(), OrderError> {
let available = stock.ok_or_else(|| OrderError::ProductNotFound("Laptop".into()))?;
if available < requested {
return Err(OrderError::InsufficientStock { available });
}
Ok(())
}
#[derive(Error, Debug)] automatically generates the std::error::Error trait implementation. #[error("...")] defines the display message for each variant, supporting placeholders like {0} (positional) or {available} (field name). When you print the error:
Error: InsufficientStock { available: 2 }
If you use {} instead of {:?} when printing, you get a friendly message: insufficient stock, 2 items left.
7. anyhow for Applications, thiserror for Libraries
When writing a binary/application (not a library for others to use), you often don’t need to distinguish error types in detail — just propagate and print. The anyhow crate is perfect for this:
# Cargo.toml
[dependencies]
anyhow = "1.0"
use anyhow::{Context, Result};
fn load_config(path: &str) -> Result<String> {
std::fs::read_to_string(path)
.with_context(|| format!("could not read config file: {path}"))
}
fn main() -> Result<()> {
let config = load_config("app.toml")?;
println!("Config: {config}");
Ok(())
}
anyhow::Result<T> is an alias for Result<T, anyhow::Error>, which can hold any error implementing the Error trait. with_context adds contextual information to the original error, making debugging easier without needing to define your own enum.
- Library (a crate others will
use): usethiserror, return a specific error enum so callers canmatchand handle each case. - Application (the final binary): use
anyhow, just propagate and log, nobody needs tomatchin detail.
8. Testing Error Behavior
Rust has a built-in test framework; you don’t need an external library for basic tests.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn returns_ok_when_stock_sufficient() {
let result = validate_order(Some(10), 5);
assert!(result.is_ok());
}
#[test]
fn returns_err_when_stock_insufficient() {
let result = validate_order(Some(2), 5);
assert!(matches!(
result,
Err(OrderError::InsufficientStock { available: 2 })
));
}
#[test]
fn returns_err_when_product_not_found() {
let result = validate_order(None, 5);
assert!(matches!(result, Err(OrderError::ProductNotFound(_))));
}
}
#[test] marks a function as a test and runs it with cargo test. matches! is a macro that checks if a value matches a given pattern — more convenient than assert_eq! when comparing enums with fields, since you don’t need to implement PartialEq for your entire error type.
running 3 tests
test tests::returns_ok_when_stock_sufficient ... ok
test tests::returns_err_when_stock_insufficient ... ok
test tests::returns_err_when_product_not_found ... ok
9. Common Mistakes
- Overusing
.unwrap(): code runs fine during dev, panics unexpectedly in production when encountering unfamiliar input. Use?,match, orunwrap_or_elsewith explicit handling instead. - Using
Stringas the error type in a public library: callers can’tmatchby error type, only compare strings — breaks if you change the message. Use an enum withthiserrorinstead ofString. - Mixing
anyhow::Errorinto your library’s public API: it hides the specific error type information, forcing callers to manuallydowncast.anyhowshould only appear in the final application layer. - Forgetting
#[derive(Debug)]: the?operator and many test macros require error types to implementDebug. Missing it, the compiler reports an unsatisfied trait bound.
Note: If you’re coming from Java and want to “simulate checked exceptions”, don’t try to mimic try/catch with macros. Instead, embrace the Rust way: errors are values, returned and passed around like any other data.
10. Conclusion
Option<T> and Result<T, E> turn handling “absence” and “failure” into a part of the type system, checked by the compiler at compile time rather than at runtime. The ? operator keeps code concise while remaining explicit. For small projects, String or anyhow is enough; for shared libraries, invest in a clear error enum with thiserror so callers can safely handle each case.