DaleSchool

Result and Error Propagation: The Magic of ?

Intermediate20min

Learning Objectives

  • Handle Result<T, E> with Ok/Err
  • Propagate errors with the ? operator
  • Create custom error types
  • Refactor code from unwrap() to ?

Working Code

In Lesson 16 you learned about Result and unwrap(). Now you'll learn how to handle errors elegantly without unwrap().

Let's build a program that reads a file:

use std::fs;

fn main() {
    // unwrap() version — panics if the file doesn't exist
    // let content = fs::read_to_string("hello.txt").unwrap();

    // match version — safe but verbose
    match fs::read_to_string("hello.txt") {
        Ok(content) => println!("File contents: {content}"),
        Err(e) => println!("Can't read file: {e}"),
    }
}

The program doesn't crash even if the file is missing! But match everywhere makes code complex.

Try It Yourself

  1. Create a hello.txt file with some content and run the program.
  2. Change the filename to something that doesn't exist and run it.
  3. Uncomment the unwrap() version and run without the file. What error message do you get?

The ? Operator — match in One Character

? can be used inside functions that return Result. On Ok, it extracts the value. On Err, it immediately returns the error.

First, here's what it looks like with match:

use std::fs;
use std::io;

fn read_greeting() -> Result<String, io::Error> {
    let content = match fs::read_to_string("hello.txt") {
        Ok(text) => text,
        Err(e) => return Err(e),  // return error immediately
    };
    Ok(format!("Greeting: {content}"))
}

With ?, you can reduce that to a single character:

use std::fs;
use std::io;

fn read_greeting() -> Result<String, io::Error> {
    let content = fs::read_to_string("hello.txt")?;
    Ok(format!("Greeting: {content}"))
}

fn main() {
    match read_greeting() {
        Ok(msg) => println!("{msg}"),
        Err(e) => println!("Error: {e}"),
    }
}

The ? in fs::read_to_string("hello.txt")? does exactly what the match above does: "Extract the value on success; propagate the error to the caller on failure."

Chaining ?

You can use ? multiple times in sequence. If any step fails, the function exits at that point:

use std::fs;
use std::io;

fn read_number_from_file() -> Result<i32, Box<dyn std::error::Error>> {
    let content = fs::read_to_string("number.txt")?;  // file read fails?
    let number = content.trim().parse::<i32>()?;       // number parse fails?
    Ok(number)
}

fn main() {
    match read_number_from_file() {
        Ok(n) => println!("Number: {n}"),
        Err(e) => println!("Error: {e}"),
    }
}

With match, this would be a nightmare of nested matches. Thanks to ?, you only see the "happy path."

Returning Result From main()

? only works inside functions that return Result. You can make main() return Result too:

use std::fs;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let content = fs::read_to_string("hello.txt")?;
    println!("{content}");
    Ok(())
}

Box<dyn std::error::Error> means "a box that can hold any error." Just memorize this pattern for now.

Before / After Refactoring

Remember the unwrap() calls from the number-guessing game in Lesson 07? Here's how ? changes things:

Before — unwrap() everywhere

use std::io;

fn main() {
    let mut input = String::new();
    io::stdin().read_line(&mut input).unwrap();
    let number: i32 = input.trim().parse().unwrap();
    println!("You entered: {number}");
}

Two unwrap() calls. Whether input reading or parsing fails, the program just dies.

After — ? operator

use std::io;

fn read_number() -> Result<i32, Box<dyn std::error::Error>> {
    let mut input = String::new();
    io::stdin().read_line(&mut input)?;
    let number = input.trim().parse::<i32>()?;
    Ok(number)
}

fn main() {
    match read_number() {
        Ok(n) => println!("You entered: {n}"),
        Err(e) => println!("Invalid input: {e}"),
    }
}

The unwrap() calls are gone, and errors no longer crash the program!

Custom Error Types

In real projects, different kinds of errors get mixed together. Define an error enum and implement From to use it seamlessly with ?:

use std::fs;
use std::io;
use std::num::ParseIntError;

#[derive(Debug)]
enum AppError {
    Io(io::Error),
    Parse(ParseIntError),
}

impl std::fmt::Display for AppError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            AppError::Io(e) => write!(f, "File error: {e}"),
            AppError::Parse(e) => write!(f, "Parse error: {e}"),
        }
    }
}

impl From<io::Error> for AppError {
    fn from(e: io::Error) -> Self {
        AppError::Io(e)
    }
}

impl From<ParseIntError> for AppError {
    fn from(e: ParseIntError) -> Self {
        AppError::Parse(e)
    }
}

fn read_config_port() -> Result<u16, AppError> {
    let content = fs::read_to_string("config.txt")?;  // io::Error -> AppError
    let port = content.trim().parse::<u16>()?;         // ParseIntError -> AppError
    Ok(port)
}

fn main() {
    match read_config_port() {
        Ok(port) => println!("Port: {port}"),
        Err(e) => println!("Config error: {e}"),
    }
}

When you implement From, the ? operator automatically converts error types. Whether it's an io::Error or ParseIntError, it becomes AppError and returns in a single Result.

Learn More: map_err()

You can also convert error types using map_err() instead of implementing From:

use std::fs;

fn read_port() -> Result<u16, String> {
    let content = fs::read_to_string("config.txt")
        .map_err(|e| format!("Failed to read file: {e}"))?;
    let port = content.trim().parse::<u16>()
        .map_err(|e| format!("Failed to parse port: {e}"))?;
    Ok(port)
}

map_err() is handy for simple cases. From is better when multiple functions share the same error type.

Exercise 1 (easy). Replace unwrap() with ? in the function below.

use std::fs;

fn read_file() -> Result<String, std::io::Error> {
    let content = fs::read_to_string("data.txt").unwrap(); // change to ?
    Ok(content)
}

fn main() {
    match read_file() {
        Ok(text) => println!("{text}"),
        Err(e) => println!("Error: {e}"),
    }
}

Exercise 2 (medium). Write a function that reads two numbers from a file and adds them. Return an error if the file doesn't exist or if parsing fails.

use std::fs;

fn sum_from_file(path: &str) -> Result<i32, Box<dyn std::error::Error>> {
    // Complete this
    // 1. Read the file (?)
    // 2. Split by lines and parse each as i32 (?)
    // 3. Return the sum
    // Hint: use lines() and parse::<i32>()
}

fn main() {
    match sum_from_file("numbers.txt") {
        Ok(total) => println!("Sum: {total}"),
        Err(e) => println!("Error: {e}"),
    }
}

Exercise 3 (challenge). Add an AppError enum and From implementations so the code below compiles with ?.

use std::fs;
use std::io;
use std::num::ParseIntError;

// Define AppError enum here
// Implement From<io::Error> and From<ParseIntError>

fn load_age(path: &str) -> Result<u8, AppError> {
    let content = fs::read_to_string(path)?;
    let age = content.trim().parse::<u8>()?;
    Ok(age)
}

fn main() {
    match load_age("age.txt") {
        Ok(age) => println!("Age: {age}"),
        Err(e) => println!("Error: {:?}", e),
    }
}

Q1. What does the ? operator do?

  • A) It always causes a panic
  • B) It ignores Err and returns a default value
  • C) On Ok it extracts the value; on Err it propagates the error to the caller
  • D) It can only handle Option

Q2. Why doesn't this code compile?

fn main() {
    let content = std::fs::read_to_string("hello.txt")?;
    println!("{content}");
}
  • A) read_to_string doesn't exist
  • B) main() doesn't return Result, so ? can't be used
  • C) content isn't mut
  • D) std::fs wasn't imported

Q3. What's the advantage of implementing From on error types?

  • A) The program runs faster
  • B) The ? operator can automatically convert between error types
  • C) Errors stop occurring
  • D) unwrap() becomes safe