Skip to Content
Course content

31: Combinators on Option and Result

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

I've spent a lot of time reviewing PRs where the author is treating Option and Result like they're just fancy wrappers that you have to "unpack" using a match statement every single time. While match is the gold standard for exhaustiveness, using it for every single transformation leads to what I call "The Pyramid of Doom"—where your actual logic is indented so far to the right that you're running out of screen real estate.

The Nested Match Trap

Let's look at a real-world scenario: imagine you're building a configuration loader. You need to find a user in a database, check if they have a custom theme setting, and if they do, parse that setting into a Theme enum. If anything fails along the way, you just want to return a default theme.

fn get_user_theme(user_id: u32) -> Theme {
    match find_user(user_id) {
        Some(user) => {
            match user.theme_preference {
                Some(pref_string) => {
                    match Theme::from_str(pref_string) {
                        Ok(theme) => theme,
                        Err(_) => Theme::Default,
                    }
                },
                None => Theme::Default,
            }
        },
        None => Theme::Default,
    }
}

This works, and it's explicit. But look at the signal-to-noise ratio. We have three levels of nesting just to perform a linear sequence of operations. If you add one more check—say, verifying the theme is actually supported by the current version of the app—you're suddenly staring at a wall of curly braces. It's tedious to write and, surprisingly, harder to read because the "happy path" is buried deep inside the nesting.

Flattening the Logic with Combinators

In Rust, we can treat Option and Result as functors (don't worry about the category theory term; just think of them as containers). Instead of pulling the value out to work on it, we send the logic into the container using combinators. The two you'll use 90% of the time are .map() and .and_then().

The distinction is critical: .map() transforms the value inside the container, but it keeps the container type the same. .and_then() (often called flatMap in other languages) is used when the transformation function itself returns an Option or Result. If you used .map() for something that returns an Option, you'd end up with a nested Option<Option<T>>, which is almost never what you want.

fn get_user_theme(user_id: u32) -> Theme {
    find_user(user_id)
        .and_then(|user| user.theme_preference) // Returns Option<String>
        .and_then(|pref| Theme::from_str(pref).ok()) // Convert Result to Option, then flatten
        .unwrap_or(Theme::Default)
}

Look at how the cognitive load changes. We've turned a branching tree into a pipeline. We start with a user, we drill down into their preference, we try to parse it, and we provide a fallback. Each step only happens if the previous step succeeded. If find_user returns None, the rest of the chain is skipped entirely, and we jump straight to unwrap_or.

Bridging the Gap Between Option and Result

One of the most powerful patterns I use is converting an Option into a Result halfway through a chain using .ok_or(). This is useful when a "missing value" should be treated as a specific error rather than just a "nothing happened" state.

Suppose we want to return an error if the user doesn't exist, but a default theme if the preference is missing. We can switch tracks like this:

fn get_theme_strict(user_id: u32) -> Result<Theme, AppError> {
    let user = find_user(user_id).ok_or(AppError::UserNotFound)?;
    
    let theme = user.theme_preference
        .and_then(|pref| Theme::from_str(pref).ok())
        .unwrap_or(Theme::Default);
        
    Ok(theme)
}

I personally prefer this "hybrid" approach. I use the ? operator to bail out early on critical failures (like the user not existing) and combinators to handle the optional, "nice-to-have" data transformations. It keeps the function flat without sacrificing the ability to provide detailed error messages.

Knowing When to Stop Chaining

A word of caution: don't let the elegance of combinators trick you into writing "one-liners from hell." If you find yourself chaining seven or eight different methods with complex closures, you've just traded the Pyramid of Doom for a Wall of Text. If the logic inside a .map() closure exceeds three lines, I usually pull it out into its own named function. The goal is clarity, not brevity.




📋 Practical Task

Exercise: Refining the API Token Validator

You are building a middleware that validates API tokens. You have the following setup:

  • fn fetch_token_metadata(token: &str) -> Option<TokenMetadata>
  • struct TokenMetadata { owner_id: u32, scope: String }
  • fn verify_scope(scope: &str) -> Result<bool, ScopeError>

Your task is to write a function is_token_valid(token: &str) -> bool. Currently, it's written using nested match statements. Rewrite it using combinators (.and_then(), .map(), etc.) to make it a concise pipeline. The logic should be: 1. Fetch the metadata. 2. If metadata exists, verify the scope. 3. If the scope verification is successful and returns true, the token is valid. 4. In any other case (metadata missing, scope error, or scope is false), return false.

// Naive implementation to be refactored:
fn is_token_valid(token: &str) -> bool {
    match fetch_token_metadata(token) {
        Some(meta) => {
            match verify_scope(&meta.scope) {
                Ok(true) => true,
                Ok(false) => false,
                Err(_) => false,
            }
        },
        None => false,
    }
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.