Skip to Content
Course content

108: The ? Operator Internals

Click on the "Edit" button in the top corner of the screen to edit your slide content.

You've probably used the ? operator a hundred times by now. It feels like magic: you slap it on the end of a Result, and if it's an error, the function just exits early. But if you treat it as a black box, you're going to hit a wall the moment your project grows beyond a single module.

Take a look at this snippet. I'm trying to read a number from a file and return it. Simple enough, right?

fn read_port_from_file() -> std::io::Result<u16> {
    let content = std::fs::read_to_string("port.txt")?;
    let port: u16 = content.trim().parse()?; 
    Ok(port)
}

The mismatched type wall

If you try to compile this, the compiler is going to yell at you. It'll tell you that std::num::ParseIntError cannot be converted to std::io::Error. This is the exact moment most learners get frustrated because they think they're doing something wrong with the ? operator itself.

Here is the secret: ? isn't just a "return if error" shortcut. It actually performs an implicit type conversion. Under the hood, the compiler expands expr? into something that looks roughly like this:

match expr {
    Ok(val) => val,
    Err(err) => return Err(std::convert::From::from(err)),
}

Notice that From::from(err) call? That's the key. The ? operator tries to convert the error returned by the expression into the error type defined in your function's return signature. In my broken example, read_to_string returns an io::Error, which matches the function signature, so it works. But parse() returns a ParseIntError. Since Rust doesn't have a built-in implementation of From<ParseIntError> for io::Error>, the code fails to compile.

Unifying errors with a custom enum

To fix this, we need to stop pretending that every error in our function is an IO error. We need a type that can represent any error that might happen in this context. I usually do this by creating a custom error enum.

Here is how I'd refactor that function to make it actually work:

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

// We tell Rust how to turn an io::Error into a ConfigError
impl From<std::io::Error> for ConfigError {
    fn from(err: std::io::Error) -> Self {
        ConfigError::Io(err)
    }
}

// And how to turn a ParseIntError into a ConfigError
impl From<std::num::ParseIntError> for ConfigError {
    fn from(err: std::num::ParseIntError) -> Self {
        ConfigError::Parse(err)
    }
}

fn read_port_from_file() -> Result<u16, ConfigError> {
    let content = std::fs::read_to_string("port.txt")?; // Calls ConfigError::from(io_err)
    let port: u16 = content.trim().parse()?;           // Calls ConfigError::from(parse_err)
    Ok(port)
}

Now, when the ? operator hits an error, it looks at the return type (ConfigError) and checks if the error it just caught can be converted into that type using the From trait. Because we implemented From for both possible error types, the "magic" works again.

I'll be honest: writing those From implementations by hand is tedious. In a real-world production codebase, I'd almost certainly use a crate like thiserror to derive those implementations for me. But understanding that ? is just a wrapper around From::from is what separates someone who guesses at their types from someone who actually controls them.




📋 Practical Task

Implementing a Multi-Source Data Loader

You are building a system that loads a user's ID from a file and then validates that ID against a mock database (which might return a custom "NotFound" error). Your goal is to use the ? operator to handle these disparate error types cleanly.

Requirements:

  • Define a custom error enum called LoaderError that can wrap both std::io::Error and a custom DatabaseError (just a simple unit struct).
  • Implement the From trait for LoaderError for both error types.
  • Write a function load_user_id() -> Result<u32, LoaderError> that:
    1. Reads a filename from a string.
    2. Parses that string into a u32.
    3. Calls a mock function check_db(id: u32) -> Result<(), DatabaseError>.
  • Use the ? operator for all three potentially failing operations.

Starter Code:

struct DatabaseError;

fn check_db(id: u32) -> Result<(), DatabaseError> {
    if id == 0 { Err(DatabaseError) } else { Ok(()) }
}

// Your implementation goes here...
Rating
0 0

There are no comments for now.

to be the first to leave a comment.