The kitchen ticket system
You are writing a function that reads a configuration file. The file might not exist. The format might be wrong. The permissions might be locked. In Python, you catch an exception and hope it works. In JavaScript, you wrap it in a try-catch block and pray. In Rust, the compiler forces you to decide exactly what happens when things go wrong. That decision changes completely depending on whether you are building a reusable library or a standalone application.
Think of error handling like a restaurant kitchen. The line cooks do not decide what to do when an ingredient runs out. They slide a ticket to the pass and wait for the head chef to decide whether to substitute an ingredient, send a note to the customer, or close the kitchen. The cooks just report the problem. The head chef handles the fallout.
Rust enforces this separation through the Result<T, E> type. Every function that can fail must declare it in its signature. The T is the success value. The E is the error type. Libraries return Result so callers can choose how to recover. Applications catch the Result at the top level, translate it into a human-readable message, and exit cleanly.
How Result and ? actually work
The Result enum has two variants: Ok(T) and Err(E). When a function succeeds, it wraps the output in Ok. When it fails, it wraps the diagnostic information in Err. The compiler tracks these wrappers through your entire call stack. You cannot accidentally drop an error. You cannot ignore a failure without explicitly opting out.
The ? operator is the engine that makes this practical. It is syntactic sugar for a match statement that either returns the error immediately or extracts the success value. When you write let x = some_function()?;, the compiler expands it to check the Result. If it is Err, the function returns that error right now. If it is Ok, the compiler unwraps the value and assigns it to x. Execution continues.
This operator relies on the From trait for automatic type conversion. If your function promises to return Result<String, MyError>, but some_function() returns Result<String, std::io::Error>, the ? operator calls From::from() behind the scenes. It converts the io::Error into your MyError before returning. Without From implementations, the ? operator refuses to compile. The compiler demands exact type matches or explicit conversions.
Minimal example
Here is the baseline pattern. A library function returns a Result. The application uses the ? operator to bubble the error up.
/// Attempts to parse a number from a string.
fn parse_value(input: &str) -> Result<i32, std::num::ParseIntError> {
// The ? operator returns early if parsing fails.
// It hands the error to the caller instead of panicking.
input.parse::<i32>()?
}
fn main() -> Result<(), std::num::ParseIntError> {
let value = parse_value("42")?;
println!("Parsed: {}", value);
Ok(())
}
The library function never prints to the console. It never decides whether to retry or abort. It just returns a Result. The main function is the only place that decides how to communicate with the user. This separation keeps your library testable and reusable. You can drop it into a web server, a CLI tool, or a desktop app without changing a single line of error logic.
Walkthrough
When parse_value runs, it calls input.parse(). If the string contains letters, parse() returns Err(ParseIntError). The ? operator catches that Err, unwraps it, and immediately returns it from parse_value. The function stops executing. Control jumps back to main. main sees the Err, hits its own ?, and returns it to the runtime. The program exits with a non-zero status code.
If the string is valid, parse() returns Ok(42). The ? operator unwraps the Ok, yields 42, and execution continues. The ? operator keeps your code flat and readable. It eliminates nested match statements that obscure the happy path. You read the code top to bottom. The error handling happens implicitly at the boundaries.
Realistic example with context
Real libraries rarely return standard library errors directly. They wrap them in custom error types so callers do not need to know which crate failed. Applications then translate those custom errors into user-facing messages. You also want to preserve the original error when adding context. This creates a chain of diagnostics that makes debugging straightforward.
use std::fs;
use std::io;
/// Represents all ways the config loader can fail.
#[derive(Debug)]
enum ConfigError {
FileNotFound,
ParseFailure(String),
}
/// Reads and validates a configuration file.
fn load_config(path: &str) -> Result<String, ConfigError> {
// Attempt to read the file contents.
// Convert io::Error into our custom variant.
let contents = fs::read_to_string(path).map_err(|_| ConfigError::FileNotFound)?;
// Validate that the file isn't empty.
if contents.trim().is_empty() {
return Err(ConfigError::ParseFailure("Config file is empty".to_string()));
}
Ok(contents)
}
fn main() {
// Applications handle the final error and exit gracefully.
match load_config("settings.toml") {
Ok(data) => println!("Loaded config: {}", data),
Err(ConfigError::FileNotFound) => eprintln!("Missing settings.toml. Please create it."),
Err(ConfigError::ParseFailure(msg)) => eprintln!("Invalid config: {}", msg),
}
}
The map_err call bridges the type gap. It takes the io::Error and transforms it into ConfigError::FileNotFound. This satisfies the compiler and keeps the error type consistent throughout the function. The main function uses a match statement to route each variant to a specific user message. This pattern scales well. You can add new variants to the enum without touching the application logic.
Pitfalls and compiler errors
The compiler will stop you from being lazy. If you try to return a Result<String, std::io::Error> from a function that promises Result<String, ConfigError>, you get E0308 (mismatched types). Rust does not automatically convert error types. You must use .map_err() or implement the From trait to bridge the gap.
Forgetting the ? operator triggers a warning about unused Result values. The compiler assumes you meant to handle it. If you intentionally ignore an error, you must write let _ = risky_operation(); to silence the warning. This is a community convention. It signals to future readers that you considered the error and deliberately chose to drop it.
Another trap is panicking in a library. Calling .unwrap() or .expect() on a Result inside a reusable crate turns a recoverable error into a fatal crash. The caller loses all control. Reserve panics for truly impossible states, like an index out of bounds on a slice you just verified, or a bug in your own logic. External failures like missing files, bad network responses, or invalid user input belong in Result.
When you mix multiple error types in a single function, you will eventually hit E0277 (trait bound not satisfied). This happens when the ? operator tries to convert an error but cannot find a From implementation. The fix is either to implement From for your custom error type, or to use a boxed trait object like Box<dyn std::error::Error> as the error type. The boxed approach trades compile-time type safety for convenience. It works fine for quick scripts. It becomes painful in large codebases where you need to match on specific error variants.
Decision matrix
Use Result<T, E> in libraries when you want callers to decide how to recover from failures. Use a custom error enum when your function can fail in multiple distinct ways that require different recovery paths. Use Box<dyn std::error::Error> in quick scripts or throwaway applications when you want to avoid defining error types and just need something that compiles. Use the ? operator to propagate errors upward instead of writing nested match statements. Use panic! only when continuing execution would violate a fundamental invariant or corrupt program state. Reach for thiserror when building production libraries to automate error trait implementations and keep your enum definitions clean. Reach for anyhow when writing applications that need flexible error context without defining custom enums.
The Rust community treats main returning Result<(), E> as a standard pattern for CLI tools. It lets the ? operator bubble all the way to the top without manual matching. For larger applications, you will eventually want structured logging. Print statements work for prototypes, but loggers give you timestamps, severity levels, and context.
Error handling in Rust is not about avoiding mistakes. It is about making mistakes explicit. The compiler forces you to trace every failure path before the program ever runs. Treat every Result as a contract. If you can explain what happens when it fails, you have finished the function.