Logo
Logo

Atharva Pandey/Lesson 2: Result and Option — The foundation

Created Sun, 07 Jul 2024 08:45:00 +0000 Modified Sun, 07 Jul 2024 08:45:00 +0000

When I first started writing Rust, I used match on every single Result and Option. My code looked like a staircase — match inside match inside match, indented halfway across the screen. Then a colleague showed me combinators, and suddenly the code read like prose instead of a tax form.

Result: More Than Just Ok and Err

You already know Result<T, E> has two variants. But Result comes with a huge set of methods that let you transform, combine, and short-circuit without writing explicit match blocks everywhere.

map — Transform the success value

map applies a function to the Ok value and leaves Err untouched. You use it when you want to transform a success without caring about failure yet.

fn parse_port(s: &str) -> Result<u16, std::num::ParseIntError> {
    s.parse::<u16>()
}

fn main() {
    let doubled: Result<u32, _> = parse_port("4000").map(|p| p as u32 * 2);
    println!("{:?}", doubled); // Ok(8000)

    let bad: Result<u32, _> = parse_port("abc").map(|p| p as u32 * 2);
    println!("{:?}", bad); // Err(invalid digit found in string)
}

The function inside map never runs if the Result is Err. That’s the whole point — you chain transformations on the happy path and errors pass through untouched.

map_err — Transform the error value

The mirror of map. Changes the error type without touching success:

use std::fmt;
use std::num::ParseIntError;

#[derive(Debug)]
struct ConfigError(String);

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "config error: {}", self.0)
    }
}

fn parse_port(s: &str) -> Result<u16, ConfigError> {
    s.parse::<u16>()
        .map_err(|e: ParseIntError| ConfigError(format!("invalid port '{}': {}", s, e)))
}

fn main() {
    match parse_port("not_a_number") {
        Ok(p) => println!("Port: {}", p),
        Err(e) => eprintln!("{}", e),
        // prints: config error: invalid port 'not_a_number': invalid digit found in string
    }
}

You’ll use map_err constantly when converting between error types at module boundaries.

and_then — Chain fallible operations

and_then is for when your transformation itself can fail. It’s like map, but the closure returns a Result:

use std::num::ParseIntError;

fn parse_and_validate_port(s: &str) -> Result<u16, String> {
    s.parse::<u16>()
        .map_err(|e: ParseIntError| format!("parse error: {}", e))
        .and_then(|port| {
            if port >= 1024 {
                Ok(port)
            } else {
                Err(format!("port {} is privileged, use >= 1024", port))
            }
        })
}

fn main() {
    println!("{:?}", parse_and_validate_port("8080"));  // Ok(8080)
    println!("{:?}", parse_and_validate_port("80"));    // Err("port 80 is privileged...")
    println!("{:?}", parse_and_validate_port("abc"));   // Err("parse error: ...")
}

If you used map here, you’d get Result<Result<u16, String>, String> — a nested Result. and_then flattens it.

unwrap_or, unwrap_or_else, unwrap_or_default

When you want to extract the value with a fallback:

fn main() {
    let port: u16 = "8080".parse().unwrap_or(3000);
    println!("{}", port); // 8080

    let port: u16 = "bad".parse().unwrap_or(3000);
    println!("{}", port); // 3000

    // unwrap_or_else takes a closure — useful when the default is expensive
    let port: u16 = "bad".parse().unwrap_or_else(|_| {
        eprintln!("falling back to default port");
        3000
    });
    println!("{}", port); // 3000

    // unwrap_or_default uses the type's Default impl
    let count: i32 = "bad".parse().unwrap_or_default();
    println!("{}", count); // 0
}

or and or_else — Fallback Results

or provides an alternative Result if the first one is Err:

use std::env;
use std::fs;

fn read_config() -> Result<String, String> {
    // Try env var first, then file
    env::var("APP_CONFIG")
        .map_err(|e| format!("env error: {}", e))
        .or_else(|_| {
            fs::read_to_string("config.toml")
                .map_err(|e| format!("file error: {}", e))
        })
}

fn main() {
    match read_config() {
        Ok(config) => println!("Config loaded: {} bytes", config.len()),
        Err(e) => eprintln!("No config found: {}", e),
    }
}

Option: The Same Toolkit, Different Semantics

Option<T> has almost the exact same set of combinators as Result. The difference is semantic — Option represents absence, not failure.

The essentials

fn main() {
    let numbers = vec![10, 20, 30];

    // map — transform if present
    let first_doubled: Option<i32> = numbers.first().map(|n| n * 2);
    println!("{:?}", first_doubled); // Some(20)

    // and_then — chain operations that might return None
    let result = numbers.first()
        .and_then(|n| if *n > 5 { Some(n * 2) } else { None });
    println!("{:?}", result); // Some(20)

    // unwrap_or
    let empty: Vec<i32> = vec![];
    let val = empty.first().copied().unwrap_or(0);
    println!("{}", val); // 0

    // filter — keep the value only if it passes a predicate
    let big: Option<&i32> = numbers.first().filter(|n| **n > 100);
    println!("{:?}", big); // None
}

ok_or — Converting Option to Result

This is a bridge you’ll cross all the time. You have an Option but need a Result because absence is an error in your context:

use std::collections::HashMap;

fn get_user_email(users: &HashMap<u64, String>, id: u64) -> Result<&String, String> {
    users.get(&id)
        .ok_or(format!("user {} not found", id))
}

fn main() {
    let mut users = HashMap::new();
    users.insert(1, String::from("atharva@example.com"));

    println!("{:?}", get_user_email(&users, 1));  // Ok("atharva@example.com")
    println!("{:?}", get_user_email(&users, 99)); // Err("user 99 not found")
}

ok_or_else takes a closure instead — use it when constructing the error is expensive.

Going the other direction: Result to Option

Sometimes you don’t care why something failed, just whether it succeeded:

fn main() {
    // .ok() drops the error
    let maybe_port: Option<u16> = "8080".parse::<u16>().ok();
    println!("{:?}", maybe_port); // Some(8080)

    // .err() drops the success
    let maybe_error = "bad".parse::<u16>().err();
    println!("{:?}", maybe_error); // Some(invalid digit...)
}

Combining Multiple Results

Real code often needs to run several fallible operations and combine their results.

The collect trick

You can collect an iterator of Results into a Result of a collection. If any element is Err, the whole thing short-circuits:

fn parse_all_ports(inputs: &[&str]) -> Result<Vec<u16>, std::num::ParseIntError> {
    inputs.iter()
        .map(|s| s.parse::<u16>())
        .collect()
}

fn main() {
    println!("{:?}", parse_all_ports(&["80", "443", "8080"]));
    // Ok([80, 443, 8080])

    println!("{:?}", parse_all_ports(&["80", "abc", "8080"]));
    // Err(invalid digit found in string)
}

This is one of those things that feels like magic the first time you see it. The FromIterator impl for Result handles the short-circuiting.

Zip-style with tuples

When you need multiple independent results and want all of them:

fn main() {
    let host: Result<String, String> = Ok(String::from("localhost"));
    let port: Result<u16, String> = Ok(8080);

    // Using and_then to combine
    let addr = host.and_then(|h| {
        port.map(|p| format!("{}:{}", h, p))
    });

    println!("{:?}", addr); // Ok("localhost:8080")
}

Patterns You’ll Actually Use

Here’s a real-world-ish function that chains combinators together. This is what idiomatic Rust error handling looks like once you get comfortable:

use std::collections::HashMap;
use std::num::ParseIntError;

#[derive(Debug)]
struct AppConfig {
    host: String,
    port: u16,
    workers: usize,
}

fn load_config(env: &HashMap<String, String>) -> Result<AppConfig, String> {
    let host = env.get("HOST")
        .cloned()
        .unwrap_or_else(|| String::from("127.0.0.1"));

    let port = env.get("PORT")
        .ok_or("PORT not set")?
        .parse::<u16>()
        .map_err(|e: ParseIntError| format!("invalid PORT: {}", e))?;

    let workers = env.get("WORKERS")
        .map(|w| w.parse::<usize>())
        .transpose()
        .map_err(|e: ParseIntError| format!("invalid WORKERS: {}", e))?
        .unwrap_or(4);

    Ok(AppConfig { host, port, workers })
}

fn main() {
    let mut env = HashMap::new();
    env.insert("PORT".into(), "8080".into());
    env.insert("WORKERS".into(), "8".into());

    match load_config(&env) {
        Ok(config) => println!("{:#?}", config),
        Err(e) => eprintln!("Config error: {}", e),
    }
}

Notice the transpose() call — that converts Option<Result<T, E>> into Result<Option<T>, E>. Incredibly useful when you have an optional field that needs parsing.

When to Use Which Combinator

A quick reference I wish I’d had when starting out:

  • I want to transform the success valuemap
  • I want to transform the error valuemap_err
  • My transformation can also failand_then
  • I want a default on failureunwrap_or / unwrap_or_else
  • I want to try an alternativeor_else
  • I want to convert Option to Resultok_or / ok_or_else
  • I want to convert Result to Option.ok() / .err()
  • I want to flip Option to Resulttranspose

Master these and you’ll rarely need to write explicit match blocks. Your code will be shorter, more expressive, and the error handling won’t obscure the business logic. That’s the whole game.