Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 11

Error Handling with `Result` and `?`

Recommended resources

Hello! Let's get started with our next lesson.

In our last session, we explored the Option<T> enum and used it to solve a practical problem in our chess game: representing an empty square on the board. Option<T> is Rust's way of handling values that might be present or absent.

Today, we'll look at Option's close relative: the Result<T, E> enum. While Option deals with absence, Result deals with operations that can either succeed or fail. This is crucial for writing robust programs that can handle things going wrong without crashing.

Our goal is to learn how to handle recoverable errors using the Result<T, E> enum and the powerful ? operator. This will be essential later in our chess project when we handle user input (e.g., parsing a move like "e2e4") or load game assets, both of which are operations that could potentially fail.

1. From Absence to Success or Failure: The Result Enum

In programming, many actions aren't guaranteed to succeed. Trying to open a file might fail if the file doesn't exist. Trying to read data from a network might fail if the connection drops. Rust gives us a tool to handle these situations explicitly: Result<T, E>.

Let's watch a short video that introduces the Result enum.

Error Handling in Rust

This clip from the Let's Get Rusty channel explains what the Result enum is and how it represents either a successful outcome or an error.

Watch from 02:51 to 03:36. Notice the similarity to Option: instead of Some and None, Result has Ok and Err.

As the video explained, Result<T, E> is an enum with two variants:

  • Ok(T): The operation was successful, and it contains the resulting value of type T.
  • Err(E): The operation failed, and it contains a value of type E that describes the error.

Just like with Option, the Rust compiler forces you to handle both Ok and Err cases, ensuring that potential errors are dealt with at compile time.


2. Handling Result with match

So, when a function returns a Result, how do you work with it? The most fundamental way is to use a match expression, just as we did with Option. This lets you define different logic for the success and failure cases.

File I/O is a classic example of an operation that can fail. Let's see how to handle the Result returned by File::open.

Error Handling in Rust

This segment demonstrates using a match expression to handle the Result returned when trying to open a file.

Watch from 03:36 to 04:56. Observe how the code explicitly handles the Ok(file) case by using the file handle and the Err(error) case by panicking.

The pattern is straightforward:

use std::fs::File;

fn main() {
    let file_result = File::open("hello.txt");

    let file_handle = match file_result {
        Ok(file) => file, // If successful, extract the file handle
        Err(error) => {
            // If it fails, panic and show the error
            panic!("Problem opening the file: {:?}", error);
        }
    };
}

This works, but panic! is a blunt instrument. It crashes your program. A more sophisticated approach is to let the function that called your code decide how to handle the error. This is called error propagation.


3. Propagating Errors and the ? Operator

Instead of handling an error immediately, you can return it from your function, passing the responsibility up the call stack. The calling function might have more context and be able to handle the error more gracefully (e.g., by trying a different file or using a default value).

Function Call Chain and Error Propagation
This diagram illustrates error propagation. If `func3` fails, it returns an error to `func2`. If `func2` can't handle it, it can also return the error to `func1`. The responsibility moves up the call stack.

You could write a function to propagate errors using match:

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

// This function returns a Result: either a String or an io::Error.
fn read_username_from_file() -> Result<String, io::Error> {
    let file_result = File::open("hello.txt");

    let mut file = match file_result {
        Ok(f) => f,
        Err(e) => return Err(e), // Early return on error
    };

    let mut username = String::new();

    match file.read_to_string(&mut username) {
        Ok(_) => Ok(username), // Success, return username
        Err(e) => Err(e),      // Failure, return the error
    }
}

This pattern is so common that Rust provides a special piece of syntax to make it clean and concise: the question mark operator (?).

The ? operator is a shortcut that does exactly what the match statements above do:

  • If a Result is Ok(value), it unwraps the value and lets the expression continue.
  • If a Result is Err(error), it immediately returns the Err(error) from the entire function.

Let's watch how the ? operator dramatically simplifies error propagation.

Error Handling in Rust

This is the most important part of our lesson. This clip from Let's Get Rusty shows how to refactor verbose match expressions into a single, elegant line using the ? operator.

Watch from 08:19 to 11:00. Pay close attention to the 'before' and 'after' code. See how the ? cleans up the logic for both opening the file and reading from it.

Using the ? operator, the bulky function from before becomes incredibly clean:

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

fn read_username_from_file() -> Result<String, io::Error> {
    let mut file = File::open("hello.txt")?; // If error, returns Err from function.
    let mut username = String::new();
    file.read_to_string(&mut username)?; // If error, returns Err from function.
    Ok(username) // If both operations succeed, return the username in an Ok.
}

There's one important rule: you can only use the ? operator in a function that returns a Result (or an Option). This makes sense, because ? needs to be able to return an Err variant if something goes wrong.


4. Hands-On: Parsing User Input

Let's practice this. A common task where errors can occur is parsing a string into a number. The user might type "123", which is valid, or "abc", which is not. Rust's built-in parse() method on strings handles this by returning a Result.

We'll build a small function that simulates reading two numbers as strings and adding them together, propagating any parsing errors that occur.

Task: Implement a function to add numbers from strings.

  1. Open your src/main.rs file. You can comment out the code from the previous lesson for now.
  2. Add the use std::num::ParseIntError; line at the top. This is the error type parse() returns when it fails to convert a string to an integer.
  3. Implement the add_numbers function as shown in the skeleton below. Use the ? operator to handle the results from the parse() calls.
  4. In main, call your function with both valid and invalid inputs to see how Result and match work together to handle success and failure.
// Add this to the top of your file
use std::num::ParseIntError;

/// Tries to parse two string slices into i32 integers and returns their sum.
/// If either string is not a valid number, it propagates the error.
fn add_numbers(a: &str, b: &str) -> Result<i32, ParseIntError> {
    // Use .parse::<i32>() to attempt the conversion.
    // Use the `?` operator to unwrap the Ok value or return the Err.
    let num_a = a.parse::<i32>()?;
    let num_b = b.parse::<i32>()?;

    // If both parsing operations succeeded, return their sum wrapped in Ok.
    Ok(num_a + num_b)
}

fn main() {
    // --- Test Case 1: Success ---
    let result_success = add_numbers("10", "20");
    match result_success {
        Ok(sum) => println!("Success! The sum is: {}", sum),
        Err(e) => println!("This should not happen: {}", e),
    }

    // --- Test Case 2: Failure ---
    let result_fail = add_numbers("10", "abc");
    match result_fail {
        Ok(sum) => println!("This should not happen: The sum is {}", sum),
        Err(e) => println!("Failure as expected! Error: {}", e),
    }
}

Run your code with cargo run. You should see the success message for the first case and the failure message for the second. You've just used Result and the ? operator to write code that is both concise and robust! This exact technique will be invaluable when we parse algebraic notation like "e2e4" for our chess game.


Conclusion

Great work today! You've added another fundamental Rust concept to your toolkit. You now know how to write functions that can fail gracefully without crashing the program.

Key Takeaways:

  • Result<T, E> is an enum used for operations that can succeed (Ok(T)) or fail (Err(E)).
  • It forces you to handle potential errors at compile time, leading to more reliable code.
  • You can handle Result values using a match expression to define logic for both success and failure.
  • The ? operator is a powerful shortcut for propagating errors: it unwraps Ok values or returns Err values immediately from the function.

In our next lesson, we'll shift back to data structures and learn about Vec<T>, Rust's version of a dynamic array or list. This will be the final piece we need before we can start organizing our project code into modules and defining the data structures for our chess game.