Error Propagation with ?
Returning Result<T, E> from every function that can fail is the right approach — but manually matching on every Result gets verbose fast. Rust provides the ? operator as a concise shorthand that propagates errors up the call stack automatically, keeping error-handling code readable without hiding the fact that errors can occur.
The Verbosity Problem
Consider reading a username from a file. Without the ? operator, every fallible step requires an explicit match:
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let file_result = File::open("username.txt");
let mut file = match file_result {
Ok(f) => f,
Err(e) => return Err(e),
};
let mut username = String::new();
match file.read_to_string(&mut username) {
Ok(_) => Ok(username),
Err(e) => Err(e),
}
}This is correct but noisy. The pattern match result { Ok(v) => v, Err(e) => return Err(e) }
repeats every time we call a fallible function. The ? operator eliminates this boilerplate.
Introducing the ? Operator
Place ? after any expression that evaluates to a Result or Option. It means: "if this is Err (or None), return it immediately from the current function; otherwise, unwrap the success value and continue." Here is the same function rewritten with ?:
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let mut file = File::open("username.txt")?; // returns Err early if open fails
let mut username = String::new();
file.read_to_string(&mut username)?; // returns Err early if read fails
Ok(username)
}The logic is identical but the noise is gone. ? makes the happy path stand out while still guaranteeing that errors are handled.
Chaining ? for Even Cleaner Code
Because ? returns the unwrapped Ok value, you can chain method calls directly:
use std::fs;
fn read_username_from_file() -> Result<String, std::io::Error> {
// Chain open -> read_to_string in one expression
fs::read_to_string("username.txt")
}
// The standard library even has fs::read_to_string for exactly this pattern.use std::fs::File;
use std::io::{BufRead, BufReader};
fn first_line(path: &str) -> Result<String, std::io::Error> {
// Multiple ? operators chained together
let file = File::open(path)?;
let mut reader = BufReader::new(file);
let mut line = String::new();
reader.read_line(&mut line)?;
Ok(line.trim_end().to_string())
}How ? Works Under the Hood
The ? operator is syntactic sugar. For Result, value? desugars to roughly:
// value? expands to approximately:
match value {
Ok(v) => v,
Err(e) => return Err(From::from(e)),
}The key detail is From::from(e). Before returning the error, ? calls the From trait to convert the error type. This means you can use ? even when the function's Err type differs from the error produced by the called function — as long as a From implementation exists between the two types.
Using ? with Option
fn first_word(sentence: &str) -> Option<&str> {
// split_whitespace().next() returns Option<&str>
// ? returns None early if there are no words
let word = sentence.split_whitespace().next()?;
Some(word)
}
fn last_char_of_first_word(sentence: &str) -> Option<char> {
// Chain two ? operators on Option
sentence.split_whitespace().next()?.chars().last()
}
fn main() {
println!("{:?}", first_word("hello world")); // Some("hello")
println!("{:?}", first_word("")); // None
println!("{:?}", last_char_of_first_word("hello world")); // Some('o')
}? in main()
By default main returns (), which means you cannot use ? in it. Rust allows main to return Result<(), E> for any error type that implements std::error::Error. When main returns Err, Rust prints the error 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.txt")?;
println!("Config: {}", content.trim());
let port: u16 = content.trim().parse()?;
println!("Port: {}", port);
Ok(())
}Box<dyn Error> is a trait object that can hold any error type. It is a convenient catch-all for main and quick scripts where you do not want to define a specific error type.
The From Trait and Automatic Error Conversion
The ? operator calls From::from(e) on the error before returning it. This means you can mix error types in one function as long as your function's error type implements From<OtherError> for each error type that ? encounters.
use std::num::ParseIntError;
use std::fmt;
#[derive(Debug)]
enum AppError {
Io(std::io::Error),
Parse(ParseIntError),
}
// Implement From so ? can convert io::Error -> AppError automatically
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self {
AppError::Io(e)
}
}
// Implement From so ? can convert ParseIntError -> AppError automatically
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> Self {
AppError::Parse(e)
}
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::Io(e) => write!(f, "I/O error: {}", e),
AppError::Parse(e) => write!(f, "parse error: {}", e),
}
}
}
fn read_port_from_file(path: &str) -> Result<u16, AppError> {
let text = std::fs::read_to_string(path)?; // io::Error -> AppError::Io via From
let port: u16 = text.trim().parse()?; // ParseIntError -> AppError::Parse via From
Ok(port)
}Box<dyn Error>: The Quick Catch-All
When you just want code to compile and errors to propagate without defining your own error type, use Box<dyn std::error::Error>:
use std::error::Error;
fn parse_and_double(s: &str) -> Result<i32, Box<dyn Error>> {
let n: i32 = s.parse()?; // ParseIntError boxed automatically
Ok(n * 2)
}
fn main() -> Result<(), Box<dyn Error>> {
println!("{}", parse_and_double("21")?); // 42
println!("{}", parse_and_double("abc")?); // returns Err, main prints it
Ok(())
}The thiserror Crate
Writing Display, Debug, and From implementations by hand for every custom error type is tedious. The thiserror crate provides a derive macro that generates all of it for you. It is the standard choice for library crates that need to expose a typed, well-documented error enum.
# Cargo.toml [dependencies] thiserror = "1"
use thiserror::Error;
#[derive(Debug, Error)]
enum AppError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("parse error: {0}")]
Parse(#[from] std::num::ParseIntError),
#[error("value {value} is out of range (expected 1..={max})")]
OutOfRange { value: u16, max: u16 },
}
fn read_port(path: &str) -> Result<u16, AppError> {
let text = std::fs::read_to_string(path)?;
let port: u16 = text.trim().parse()?;
if port == 0 {
return Err(AppError::OutOfRange { value: 0, max: 65535 });
}
Ok(port)
}The anyhow Crate
anyhow is the complement to thiserror. Where thiserror is for libraries that need to expose a typed error API, anyhow is for application code where you care about propagating and displaying errors rather than matching on specific variants.
# Cargo.toml [dependencies] anyhow = "1"
use anyhow::{Context, Result};
fn read_config(path: &str) -> Result<String> {
// .context() adds a human-readable message to the error chain
let text = std::fs::read_to_string(path)
.with_context(|| format!("failed to read config from {:?}", path))?;
Ok(text)
}
fn parse_port(text: &str) -> Result<u16> {
let port: u16 = text.trim().parse()
.context("port must be a number between 1 and 65535")?;
Ok(port)
}
fn main() -> Result<()> {
let text = read_config("config.txt")?;
let port = parse_port(&text)?;
println!("listening on port {}", port);
Ok(())
}anyhow::Result<T> is shorthand for Result<T, anyhow::Error>. Every error type that implements std::error::Error can be converted into anyhow::Error automatically by ?, so you never need to write From impls in application code.
Error Propagation in Real Code
Here is a realistic example combining file reading, JSON parsing, and HTTP (illustrative):
use anyhow::{Context, Result};
use std::fs;
#[derive(Debug)]
struct Config {
host: String,
port: u16,
}
fn load_config(path: &str) -> Result<Config> {
let text = fs::read_to_string(path)
.with_context(|| format!("could not open config file: {}", path))?;
// Imagine parsing TOML or JSON here
let mut host = String::from("localhost");
let mut port: u16 = 8080;
for line in text.lines() {
if let Some(value) = line.strip_prefix("host=") {
host = value.to_string();
} else if let Some(value) = line.strip_prefix("port=") {
port = value.parse()
.with_context(|| format!("invalid port value: {:?}", value))?;
}
}
Ok(Config { host, port })
}
fn main() -> Result<()> {
let config = load_config("server.conf")?;
println!("Starting server on {}:{}", config.host, config.port);
Ok(())
}When NOT to Use ?
The ? operator propagates errors to the caller. That is exactly what you want most of the time — but not always. Avoid ? when:
You want to handle the error locally — match on the
Resultor useunwrap_or,unwrap_or_else,map_err, orok()insteadThe function returns a different type —
?onResultdoes not work in a function returningOption(and vice versa) unless you convert firstYou want to log and continue — use
if let Err(e) = result { log(e); }rather than bailing outThe error is expected and routine — consider returning a default value with
unwrap_or_default()orunwrap_or(fallback)
fn process_files(paths: &[&str]) {
for path in paths {
// Handle errors locally — log and skip rather than propagate
match std::fs::read_to_string(path) {
Ok(content) => println!("{}: {} bytes", path, content.len()),
Err(e) => eprintln!("skipping {}: {}", path, e),
}
}
}
fn find_config() -> Option<String> {
// Convert Result to Option when the error details do not matter
std::fs::read_to_string("config.txt").ok()
}
fn port_or_default(text: &str) -> u16 {
// Use a fallback instead of propagating
text.trim().parse().unwrap_or(8080)
}Summary
Tool | Use when |
|---|---|
| Propagating errors up to the caller — the default choice |
| Handling the error locally instead of propagating |
| Providing a fallback value on error |
| Quick catch-all for |
| Typed library errors with |
| Application code — easy context, no manual |