RustError Propagation (?)

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:

RUST
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 ?:

RUST
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:

RUST
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.

RUST
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:

RUST
// 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.

Note
For `Option`, `?` desugars similarly: `None` becomes `return None`, and `Some(v)` unwraps to `v`. You can only use `?` on `Option` inside a function that returns `Option`.
Using ? with Option

RUST
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.

RUST
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.

Tip
For real applications prefer a concrete error type or a crate like `anyhow`. `Box<dyn Error>` erases type information, making it harder for callers to match on specific error variants.
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.

RUST
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>:

RUST
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(())
}
Note
`Box<dyn Error>` works because the standard library implements `From<E: Error> for Box<dyn Error>` — any error type can be automatically boxed by `?`.
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.

TOML
# Cargo.toml
[dependencies]
thiserror = "1"

RUST
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)
}
Tip
`#[from]` on a variant field tells `thiserror` to generate a `From` implementation automatically, so `?` can convert that error type without any extra code from you.
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.

TOML
# Cargo.toml
[dependencies]
anyhow = "1"

RUST
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):

RUST
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 Result or use unwrap_or, unwrap_or_else, map_err, or ok() instead

  • The function returns a different type? on Result does not work in a function returning Option (and vice versa) unless you convert first

  • You want to log and continue — use if let Err(e) = result { log(e); } rather than bailing out

  • The error is expected and routine — consider returning a default value with unwrap_or_default() or unwrap_or(fallback)

RUST
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
Success
The `?` operator is Rust's most ergonomic tool for error handling. It propagates errors automatically, converts types via `From`, and keeps the happy path readable. Use it freely in functions that return `Result` or `Option`. Choose `thiserror` for library error types and `anyhow` for application-level error handling where matching on specific variants is not required.

Tool

Use when

? operator

Propagating errors up to the caller — the default choice

match / if let

Handling the error locally instead of propagating

unwrap_or / unwrap_or_else

Providing a fallback value on error

Box<dyn Error>

Quick catch-all for main or prototypes

thiserror

Typed library errors with #[derive(Error)]

anyhow

Application code — easy context, no manual From impls