Home/Error Handling Excellence/Result Combinators

Result Combinators

and_then, or_else, map_err mastery

intermediate
resultcombinatorsfunctional
🎮 Interactive Playground

What are Result Combinators?

Result combinators are methods on Result that allow functional-style error handling without explicit match statements. They enable chaining operations, transforming values, and handling errors elegantly.

// Without combinators (imperative)
let result = read_file();
let data = match result {
    Ok(content) => content,
    Err(e) => return Err(e),
};

// With combinators (functional)
let data = read_file()?;

Core Combinators

map() - Transform Success Value

fn parse_user_id(input: &str) -> Result<UserId, ParseError> {
    input.parse::<u64>()
        .map(UserId::new)  // Transform u64 -> UserId
        .map_err(|_| ParseError::InvalidUserId)
}

// Real-world: HTTP status to custom type
fn fetch_data() -> Result<Response, Error> {
    http_get("/api/data")
        .map(|body| Response { body, cached: false })
}

map_err() - Transform Error

fn read_config() -> Result<Config, AppError> {
    std::fs::read_to_string("config.toml")
        .map_err(|e| AppError::ConfigRead {
            path: "config.toml".into(),
            source: e,
        })
}

// Chain multiple map_err
fn process() -> Result<(), AppError> {
    std::fs::read("data")
        .map_err(AppError::from)?  // io::Error -> AppError
}

and_then() - Chaining Fallible Operations

fn get_user_email(user_id: u64) -> Result<String, DbError> {
    find_user(user_id)  // Result<User, DbError>
        .and_then(|user| {
            if user.email.is_empty() {
                Err(DbError::NoEmail)
            } else {
                Ok(user.email)
            }
        })
}

// Real-world: database query chain
fn fetch_user_posts(user_id: u64) -> Result<Vec<Post>, Error> {
    get_user(user_id)
        .and_then(|user| get_posts_by_author(user.name))
        .and_then(|posts| filter_published(posts))
}

or_else() - Fallback on Error

fn read_config() -> Result<Config, Error> {
    std::fs::read_to_string("config.toml")
        .or_else(|_| std::fs::read_to_string("config.yaml"))
        .or_else(|_| Ok(Config::default()))  // Ultimate fallback
}

// Real-world: cache miss fallback
fn get_data(key: &str) -> Result<Data, Error> {
    cache_get(key)
        .or_else(|_| db_fetch(key).map(|data| {
            cache_set(key, &data);
            data
        }))
}

unwrap_or() / unwrap_or_else() - Default Values

// Static default
let value = config.get("timeout").unwrap_or(30);

// Computed default
let value = expensive_computation()
    .unwrap_or_else(|_| {
        log_error("Computation failed, using fallback");
        fallback_value()
    });

// Real-world: config with defaults
fn get_port() -> u16 {
    std::env::var("PORT")
        .ok()
        .and_then(|s| s.parse().ok())
        .unwrap_or(8080)
}

Real-World Example 1: HTTP Client Chain (Network)

use anyhow::{Context, Result};

async fn fetch_user_profile(user_id: u64) -> Result<UserProfile> {
    http_get(&format!("/users/{}", user_id))
        .await
        .context("Failed to fetch user")?
        .error_for_status()
        .context("API returned error status")?
        .json::<User>()
        .await
        .context("Failed to parse JSON")?
        .try_into()  // User -> UserProfile
        .map_err(|e| anyhow!("Invalid profile data: {}", e))
}

// With combinator chain
async fn fetch_with_retry(url: &str) -> Result<Response> {
    http_get(url)
        .await
        .or_else(|_| async {
            tokio::time::sleep(Duration::from_secs(1)).await;
            http_get(url).await
        })
        .or_else(|_| async {
            tokio::time::sleep(Duration::from_secs(2)).await;
            http_get(url).await
        })
}

Real-World Example 2: Database Query Pipeline (Systems)

fn get_active_users() -> Result<Vec<UserSummary>, DbError> {
    query("SELECT * FROM users WHERE active = true")
        .fetch_all()
        .and_then(|rows| {
            rows.into_iter()
                .map(|row| parse_user(&row))
                .collect::<Result<Vec<_>, _>>()
        })
        .map(|users| {
            users.into_iter()
                .map(|u| UserSummary {
                    id: u.id,
                    name: u.name,
                })
                .collect()
        })
}

// Transaction with rollback
fn transfer_funds(from: u64, to: u64, amount: f64) -> Result<(), DbError> {
    begin_transaction()
        .and_then(|tx| {
            deduct_balance(&tx, from, amount)?;
            add_balance(&tx, to, amount)?;
            Ok(tx)
        })
        .and_then(|tx| tx.commit())
        .or_else(|e| {
            log_error("Transaction failed, rolling back");
            Err(e)
        })
}

Advanced Combinators

ok_or() / ok_or_else() - Option to Result

fn find_user(id: u64) -> Result<User, Error> {
    users.get(&id)
        .cloned()
        .ok_or(Error::NotFound)
}

// Lazy error creation
fn find_user_lazy(id: u64) -> Result<User, Error> {
    users.get(&id)
        .cloned()
        .ok_or_else(|| Error::NotFound {
            resource: "User".into(),
            id
        })
}

transpose() - Swap Result and Option

// Option<Result<T, E>> -> Result<Option<T>, E>
let result: Result<Option<Data>, Error> =
    optional_fetch()  // Option<Result<Data, Error>>
        .transpose();

// Real-world: optional config field
fn load_optional_feature() -> Result<(), Error> {
    config.get("feature_flag")
        .map(|flag| parse_feature(flag))
        .transpose()?;  // Propagate parse error if Some
    Ok(())
}

flatten() - Nested Results

// Result<Result<T, E>, E> -> Result<T, E>
let result: Result<Data, Error> = outer_operation()
    .map(|inner_result| inner_result)
    .flatten();

Iterator + Result Patterns

collect() with Results

fn parse_all(inputs: &[&str]) -> Result<Vec<i32>, ParseError> {
    inputs.iter()
        .map(|s| s.parse::<i32>())
        .collect::<Result<Vec<_>, _>>()
        .map_err(|e| ParseError::InvalidNumber)
}

// Short-circuit on first error
let numbers: Result<Vec<_>, _> =
    strings.iter().map(|s| s.parse()).collect();

partition() for Errors and Success

let results: Vec<Result<Data, Error>> = process_batch();

let (successes, errors): (Vec<_>, Vec<_>) =
    results.into_iter()
        .partition(Result::is_ok);

let successes: Vec<Data> =
    successes.into_iter().map(Result::unwrap).collect();
let errors: Vec<Error> =
    errors.into_iter().map(Result::unwrap_err).collect();

Combinator Chains

Complex Pipeline

fn process_user_data(user_id: u64) -> Result<Report, Error> {
    fetch_user(user_id)
        .and_then(validate_user)
        .and_then(fetch_user_activity)
        .map(calculate_metrics)
        .and_then(generate_report)
        .map_err(|e| Error::ProcessingFailed {
            user_id,
            source: Box::new(e)
        })
}

Early Return Pattern

fn validate_and_process(input: Input) -> Result<Output, Error> {
    let validated = validate(input)?;
    let processed = process(validated)?;
    let finalized = finalize(processed)?;
    Ok(finalized)
}

// Same with combinators
fn validate_and_process(input: Input) -> Result<Output, Error> {
    validate(input)
        .and_then(process)
        .and_then(finalize)
}

When to Use Each Combinator

| Combinator | Use When | Example |

|------------|----------|---------|

| map | Transform success value | Convert types |

| map_err | Transform error | Add context |

| and_then | Chain operations | Sequential queries |

| or_else | Provide fallback | Cache miss -> DB |

| unwrap_or | Default value | Config defaults |

| ok_or | Option -> Result | Collection lookup |

⚠️ Anti-Patterns

⚠️ ❌ Mistake #1: Overusing unwrap()

// BAD: Panics on error
let value = dangerous_operation().unwrap();

// GOOD: Handle error
let value = dangerous_operation()
    .unwrap_or_else(|e| {
        log_error(e);
        default_value()
    });

⚠️ ❌ Mistake #2: Ignoring Errors

// BAD: Silently ignores error
let _ = save_to_file(data);

// GOOD: At least log
save_to_file(data)
    .unwrap_or_else(|e| log_error("Save failed: {}", e));

⚠️ ❌ Mistake #3: Deep Combinator Nesting

// BAD: Hard to read
result.map(|x| x.map(|y| y.and_then(|z| process(z))))

// GOOD: Extract functions
result.and_then(process_nested)

fn process_nested(x: X) -> Result<Z, Error> {
    x.map(|y| y.and_then(process))
}

Exercises

Exercise 1: Build a Parser Chain

Create a config parser using only combinators (no match).

Hints:
  • map for transformations
  • and_then for validation
  • or_else for defaults

Exercise 2: HTTP Retry Logic

Implement retry with exponential backoff using combinators.

Hints:
  • or_else for retry
  • async combinators
  • closure for delay

Exercise 3: Batch Processor

Process items in batch, collect successes and errors separately.

Hints:
  • Iterator + Result
  • partition for separation
  • collect for aggregation

Further Reading

Real-World Usage

🦀 serde

Extensive use of combinators for parsing.

View on GitHub

🦀 tokio

Async combinators for futures.

View on GitHub

🦀 reqwest

HTTP client with combinator chains.

View on GitHub

🎮 Try it Yourself

🎮

Result Combinators - Playground

Run this code in the official Rust Playground