You are building a command-line tool that reads a configuration file
The file might not exist. It might be corrupted. The disk might be full. In a language with exceptions, you wrap the call in a try-catch and hope the error message makes sense later. In Rust, the compiler refuses to let you ignore the possibility of failure. Every function that can fail declares it. You have to decide what to do before the code even runs.
Rust splits error handling into two buckets. Recoverable errors use Result<T, E>. Unrecoverable errors use panic!. Think of Result as a sealed envelope. The function hands you the envelope. It contains either the data you asked for or a note explaining why it failed. You must open the envelope to proceed. panic! is the kitchen catching fire. The program stops immediately, unwinds the stack, and prints a traceback. You don't recover from a panic. You restart.
Treat Result as a value, not an exception. You own the error.
The minimal pattern
Most error handling in Rust revolves around Result<T, E> and the ? operator. Result is an enum with two variants: Ok(T) for success and Err(E) for failure. The ? operator propagates errors automatically. If the value is Ok, it extracts the inner value. If it is Err, it returns the error from the current function immediately.
use std::fs;
/// Reads a file and returns its contents or an IO error.
fn read_config(path: &str) -> Result<String, std::io::Error> {
// fs::read_to_string returns a Result.
// The ? operator extracts the value on success.
// On error, it returns early from this function with the error.
fs::read_to_string(path)
}
fn main() {
// main can return Result to let the OS handle the exit code.
// This is a common convention for CLI tools.
match read_config("config.toml") {
Ok(contents) => println!("Config: {contents}"),
Err(e) => eprintln!("Failed to read config: {e}"),
}
}
When read_config calls fs::read_to_string, the filesystem operation might fail. The ? operator checks the result. If it is Ok, the string flows through. If it is Err, ? immediately returns that error from read_config. The function stops. No manual match needed for propagation. In main, the match forces you to handle both cases. You can print the error or take action. The compiler guarantees you cannot access contents without proving the result is Ok.
Trust the type system on errors. If the code compiles, you handled the failure.
Conventions that pay off
The community follows a few small patterns that make error handling readable and consistent.
Use eprintln! for error messages. println! writes to standard output. eprintln! writes to standard error. This separation lets users pipe the successful output to another tool while still seeing errors on the console.
Return Result<(), E> from main in command-line tools. The compiler provides a special implementation for main. If main returns a Result, the program exits with a non-zero status code on error and prints the error message. This saves you from writing a boilerplate match in every binary.
Write let _ = result; when you intentionally ignore a result. The compiler warns about unused results by default. Assigning to _ signals to readers that you considered the value and chose to drop it.
Realistic error composition
Real programs combine multiple sources of failure. You might read a file and then parse a number. The file read can fail with std::io::Error. The parse can fail with std::num::ParseIntError. You cannot return both types directly. You need a way to unify them.
The standard approach is a custom error enum. Each variant wraps one of the underlying errors. You use map_err to convert the specific errors into your custom type so the ? operator can propagate them.
use std::fs;
use std::num::ParseIntError;
/// Custom error type to combine IO and parsing failures.
#[derive(Debug)]
enum ConfigError {
Io(std::io::Error),
Parse(ParseIntError),
}
/// Reads a file and parses the first line as an integer.
fn read_port(path: &str) -> Result<u16, ConfigError> {
// Read the file, mapping the IO error to our custom variant.
// map_err transforms the error type so ? can propagate it.
let content = fs::read_to_string(path)
.map_err(ConfigError::Io)?;
// Parse the integer, mapping the parse error.
let port: u16 = content
.trim()
.parse()
.map_err(ConfigError::Parse)?;
Ok(port)
}
Most crates use thiserror to derive Error implementations. Writing map_err manually is educational, but in production code, you'll likely see #[derive(Error)]. The thiserror crate generates the boilerplate for implementing std::error::Error and the From conversions automatically.
Don't leak implementation details. Wrap external errors in your own type.
How the ? operator converts errors
The ? operator does more than propagate. It converts errors. If the function returns Result<T, ConfigError> and you use ? on a Result<T, std::io::Error>, Rust looks for a From<std::io::Error> for ConfigError implementation. If it exists, ? calls From::from to convert the error automatically. You don't need map_err if From is implemented.
This is why thiserror is so popular. It generates the From impls for you. When you annotate a variant with #[from], the crate writes the conversion code. The ? operator then works seamlessly without explicit mapping.
If you forget the From impl, the compiler rejects the code with E0277 (trait bound not satisfied). It tells you the error types don't match and no conversion exists. The error message usually points to the ? operator and suggests implementing From.
Implement From for your error variants. Let ? do the heavy lifting.
Pitfalls and compiler errors
Calling unwrap() on a Result panics if the value is Err. This turns a recoverable error into a crash. Use unwrap() only in tests or when you have proven the error is impossible. If you reach for unwrap, ask yourself if the program can actually survive the failure.
Shadowing errors is a common trap. When chaining ? operators, Rust infers the error type. If one call returns std::io::Error and another returns ParseIntError, the compiler cannot unify them. You get a type mismatch error. You need a custom error type or a trait object like Box<dyn std::error::Error>.
If you try to return a Result<String, std::io::Error> from a function that returns String, the compiler rejects it with E0308 (mismatched types). You must handle the error or propagate it. The compiler will not silently drop the error.
Don't fight the compiler here. Reach for a custom error enum.
When to use what
Use Result<T, E> for recoverable errors where the caller can retry, fallback, or report the issue. Use panic! for programming bugs, invariants that should never be violated, or unrecoverable states like out-of-memory. Use the ? operator to propagate errors up the call stack when the current function cannot handle the failure meaningfully. Use map_err to convert error types when you need to unify different error sources into a single return type and From is not implemented. Use unwrap_or or unwrap_or_else when a sensible default exists and the error is expected or ignorable. Use thiserror to define custom error enums in library code; it handles the boilerplate of implementing the std::error::Error trait. Use Box<dyn std::error::Error> in main functions or simple scripts where you want to accept any error type without defining a custom enum.
Make the compiler your safety net. If the code compiles, the errors are handled.