Error Propagation with ?: So Simple, So Smart

Day 24… today we’re digging into one of my favorite “why doesn’t every language have this?” features in Rust: the ? operator.

If you’ve spent any time in C#, you’re no stranger to the good old try/catch flow. You wrap some code in a try, you catch the exception, and you hope you didn’t forget to check for null or some unexpected state.

Rust takes a different approach with Result<T,E> and the magic of the ? operator. It keeps your error handling clean, readable, and safe without the overhead (or drama) of exceptions.

The Usual Suspects: C# try/catch

Let’s start with what you know:

try
{
    var data = File.ReadAllText("config.txt");
    Console.WriteLine(data);
}
catch (IOException ex)
{
    Console.WriteLine($"Failed to read file: {ex.Message}");
}

It works. But it’s noisy. And if you’re calling multiple methods that could throw, your try/catch blocks can quickly become a tangle of indentation and exception juggling.

Rust’s Way: Result + ? = Clean

In Rust, instead of exceptions, functions return Result<T,E>. And instead of wrapping every call in a match or if statement, you can use the ? operator to automatically return early if there’s an error.

Here’s a function that reads a file and propagates errors up the call stack:

use std::fs::File;
use std::io::{self, Read};

fn read_file_contents(path: &str) -> Result<String, io::Error> {
    let mut file = File::open(path)?; // if this fails, the error is returned immediately
    let mut contents = String::new();
    file.read_to_string(&mut contents)?; // same here
    Ok(contents)
}

fn main() {
    match read_file_contents("config.txt") {
        Ok(data) => println!("File contents: {}", data),
        Err(e) => println!("Failed to read file: {}", e),
    }
}

No try, no catch, no drama. The ? says: “If this operation fails, bail out and return the error. Otherwise, keep going.”

Why This Is So Smart

The brilliance of the ? operator is in its simplicity:

  • It reduces boilerplate. No need for repetitive match statements.

  • It’s explicit. You can see exactly where errors might occur.

  • It’s enforced by the type system. Rust won’t let you forget to handle an error.

Compare that with chaining methods in C#, where you either:

  • Hope nothing throws, or

  • Wrap everything in try/catch, or

  • Check every return value manually if you’re avoiding exceptions.

Even Cleaner: Using thiserror and Custom Errors

Rust makes it easy to define your own error types and still use ?. Example:

use thiserror::Error;

#[derive(Error, Debug)]
pub enum MyError {
    #[error("IO error: {0}")]
    Io(#[from] io::Error),
    #[error("Invalid input!")]
    InvalidInput,
}

fn read_file(path: &str) -> Result<String, MyError> {
    let mut file = File::open(path)?; // auto-converts io::Error into MyError::Io
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    Ok(contents)
}

This keeps your error types meaningful without sacrificing the ease of ?.

Wrapping It Up

Error handling is one of those areas where Rust’s philosophy really shines: catch problems early, handle them clearly, and avoid surprise runtime crashes.

The ? operator makes error propagation so simple that once you get used to it, going back to nested try/catch blocks feels like using a fax machine.

Tomorrow, we’ll talk about panic and how Rust handles those truly unrecoverable situations. See you then!

Work with me

Need this kind of thinking on your team?

Fractional architecture, a decision brief, or a workshop built around the problems in these articles.

Get in touch