Recoverable Errors with Result
Learn how to handle recoverable errors in Rust using the Result enum, match, the ? operator, custom error types, and idiomatic patterns.
Most errors in software are recoverable. A file wasn’t found, a network request timed out, a user typed letters where a number was expected. These situations don’t warrant crashing the entire program. Rust provides the Result type to model operations that can succeed or fail, forcing the programmer to acknowledge both possibilities at compile time.
This document covers Result inside out: how the enum is defined, how to extract values safely, how to propagate errors up the call stack, and how to craft idiomatic custom error types. By the end, you’ll be able to handle failures explicitly without cluttering your code.
The Result Enum
Result is a generic enum with two variants:
enum Result<T, E> {
Ok(T),
Err(E),
}
T is the type of the success value. E is the type of the error. The two variants are mutually exclusive: a Result is either an Ok containing a value, or an Err containing an error. There is no "null" or missing state in between.
The type system makes errors impossible to ignore. If a function returns a Result, the caller must handle both possibilities or explicitly pass the buck. The compiler enforces this through the #[must_use] attribute on Result, which emits a warning if you silently discard the value.
Don’t Ignore Result Values:
Discarding a Result without using the value will trigger a compiler warning. If you intentionally ignore the error, use let _ = ... or call .ok(); to explicitly suppress it. Unhandled Result values are one of the most common beginner surprises.
Consider the standard File::open function:
use std::fs::File;
fn main() {
let greeting_file_result = File::open("hello.txt");
// greeting_file_result is Result<File, std::io::Error>
}
The call might succeed and give you a file handle (Ok(File)), or it might fail — the file could be missing, or you might lack permission. The function encodes both outcomes in its return type. The compiler’s job is to make sure you don’t forget about the failure path.
How beginners should think about it: Imagine Result as a labeled box. When you open it, you’ll find either a success message and a useful object, or an error note explaining what went wrong. You must open the box before using the contents — you can’t just grab the object and hope for the best.
Catching Errors with match
A match expression is the most explicit way to unpack a Result. You write one arm for Ok and one for Err.
use std::fs::File;
fn main() {
let greeting_file_result = File::open("hello.txt");
let greeting_file = match greeting_file_result {
Ok(file) => file,
Err(error) => panic!("Problem opening the file: {:?}", error),
};
}
If File::open succeeds, greeting_file gets the file handle. If it fails, the program panics with a message that includes the error details. This style is clear, but it quickly grows verbose when you need to distinguish between different kinds of errors.
Rust’s io::Error type provides a kind() method that returns an io::ErrorKind enum. You can use a match guard to branch on the specific error cause:
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let greeting_file_result = File::open("hello.txt");
let greeting_file = match greeting_file_result {
Ok(file) => file,
Err(ref error) if error.kind() == ErrorKind::NotFound => {
match File::create("hello.txt") {
Ok(fc) => fc,
Err(e) => panic!("Problem creating the file: {:?}", e),
}
}
Err(error) => {
panic!("Problem opening the file: {:?}", error)
}
};
}
The ref in Err(ref error) creates a reference to the error value instead of moving it, which is required when you use error again later. The guard if error.kind() == ErrorKind::NotFound ensures that the arm only fires when the file doesn’t exist. When it does, we attempt to create the file. If creation fails, a different panic message is emitted. All other errors still hit the final Err arm and panic.
match Is Explicit but Low‑Level:
match leaves no doubt about what happens in every case, but it can produce deeply nested code. Later sections introduce operators and combinators that express the same logic with less ceremony. The match version is a great starting point for understanding the flow.
Result Type Aliases
Many standard library modules define a type alias for Result with a fixed error type. For I/O operations, you’ll find:
type IoResult<T> = Result<T, std::io::Error>;
This is pure syntactic convenience. IoResult<File> is identical to Result<File, std::io::Error>. Using the alias saves keystrokes when the same error type repeats across several function signatures.
use std::fs::File;
use std::io::{self, Read};
fn read_file_contents(path: &str) -> io::Result<String> {
let mut file = File::open(path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents)
}
Common mistake: Believing that the alias somehow changes the type’s behavior. It doesn’t. io::Result<T> is not a new type; it’s just a shorter way to write Result<T, io::Error>. All methods on Result work identically.
Propagating Errors with the ? Operator
The ? operator is the ergonomic heart of Rust error handling. It replaces a multi‑line match that either unwraps the Ok value or returns the Err early.
use std::fs;
fn read_username_from_file() -> Result<String, std::io::Error> {
let username = fs::read_to_string("username.txt")?;
Ok(username.trim().to_string())
}
The line fs::read_to_string("username.txt")? expands to something like:
let username = match fs::read_to_string("username.txt") {
Ok(s) => s,
Err(e) => return Err(e.into()),
};
Two things happen: the Ok value is unwrapped, and the error is returned immediately — with an automatic conversion if the surrounding function expects a different error type.
Seamless Error Conversion:
If your function returns a custom error type that implements From<std::io::Error>, the ? operator will convert the error automatically. No explicit map_err needed. This is the key to composing functions with different error types.
You can chain multiple ? calls:
use std::fs;
use std::io;
fn process(path: &str) -> Result<String, io::Error> {
let content = fs::read_to_string(path)?;
let first_line = content.lines().next().ok_or(io::Error::new(
io::ErrorKind::InvalidData,
"file is empty",
))?;
Ok(first_line.to_owned())
}
Every ? is a potential early return. The code reads top‑to‑bottom, and you can see exactly where failures are propagated without scanning for match blocks.
? Only Works Inside Functions That Return Result or Option:
If you try to use ? inside main() when main returns (), the compiler will stop you. The ? operator needs a compatible return type to propagate errors to. Later, we’ll see how to adjust main’s signature to support ?.
Working with Multiple Error Types
A function that calls several fallible operations often encounters different error types — an io::Error from reading a file, a ParseIntError from a number conversion, and maybe a custom domain error. You need a way to unify them into a single return type.
The right choice depends on whether you’re writing an application or a library, and how much type information the caller needs.
Define an enum with a variant for each possible error, and implement From for automatic conversion.
use std::io;
use std::num::ParseIntError;
#[derive(Debug)]
enum AppError {
Io(io::Error),
Parse(ParseIntError),
}
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) }
}
fn do_work() -> Result<(), AppError> {
let content = std::fs::read_to_string("numbers.txt")?; // io::Error → AppError
let number: i32 = content.trim().parse()?; // ParseIntError → AppError
println!("{}", number);
Ok(())
}
Each ? converts the error type using the From impls. The caller gets a concrete enum and can match on individual variants.
Common mistake when using a custom enum: Forgetting to implement From for each wrapped error type. Without it, ? won’t convert the error, and you’ll face a type mismatch. With thiserror, the #[from] attribute handles this automatically.
Handling Errors in main()
In many codebases, main returns nothing (()). To use ? inside main, you can change its signature to return a Result. The standard library supports fn main() -> Result<(), E> as long as E implements std::fmt::Debug. When an Err bubbles up to main, the program prints the error using its Debug representation and exits with a non‑zero status code.
use std::error::Error;
use std::fs;
fn main() -> Result<(), Box<dyn Error>> {
let content = fs::read_to_string("config.toml")?;
println!("Config: {}", content);
Ok(())
}
With anyhow, the same pattern is even terser:
use anyhow::Result;
fn main() -> Result<()> {
let content = std::fs::read_to_string("config.toml")?;
println!("Config: {}", content);
Ok(())
}
Why Debug and Not Display?:
The Debug bound ensures that any error type, including those from unknown crates, can be printed. Most error types implement Debug automatically via #[derive(Debug)]. If you want a prettier user‑facing message, handle the error explicitly in main with a match or if let Err.
If you prefer to keep main returning () and still handle errors gracefully, you can call .unwrap() or .expect() inside. For prototypes this is fine; for production code, returning a Result from main is cleaner and gives the caller (the operating system) a meaningful exit code.
Declaring Custom Error Types
When a library needs to report multiple failure modes, a custom error enum is the idiomatic choice. You can write the boilerplate by hand or use thiserror.
use std::fmt;
use std::error::Error;
#[derive(Debug)]
enum ConfigError {
NotFound(String),
InvalidFormat { line: usize, message: String },
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::NotFound(path) => write!(f, "config file not found: {}", path),
ConfigError::InvalidFormat { line, message } =>
write!(f, "invalid format at line {}: {}", line, message),
}
}
}
impl Error for ConfigError {}
Implementing Display provides a human‑readable error message. The Error trait can be left empty; it signals that this type is an error. Once Display and Debug are satisfied, the type can be used with Box<dyn Error> or returned from main.
Don’t Skip Display:
Without a Display implementation, your error type cannot be printed with {} and won’t work with many error‑handling utilities. Even if you only use Debug formatting, implement Display to stay compatible with the wider ecosystem.
Prefer Idiomatic Error Types
With so many tools, it’s easy to over‑engineer error handling early. The Rust community has settled on a few patterns that balance clarity and maintainability.
- Library boundaries: Define structured error enums with
thiserror. Downstream code can match on specific variants and react accordingly. - Application code: Use
anyhoworBox<dyn Error>while the application is small. Switch to typed errors only when callers need to programmatically distinguish error variants. - The
?operator everywhere: Propagate errors with?instead of writing explicitmatchblocks. The operator is fast, readable, and automatically converts types whenFromis implemented. .expect()over.unwrap(): The.expect()method lets you attach a human‑readable reason for the assumption that the operation should never fail. If it does fail, that message appears in the panic output, saving debugging time.- Avoid string errors: Returning
Err("something went wrong".to_string())discards type information. It’s fine for tiny scripts but creates fragile string‑matching logic in larger systems.
A practical rule of thumb: start with Box<dyn Error> or anyhow. When you find yourself writing if let Err(e) = ... and then needing to inspect the error kind, that’s the signal to introduce a custom error enum. Let the codebase guide the complexity, not the other way around.