Functional Error Handling Patterns in Rust

Handle recoverable errors with Result and the ? operator, and use panic! for unrecoverable failures in Rust.

When errors stop being surprises

You're building a CLI tool. It needs to read a config file, parse the JSON, and then connect to a database. In JavaScript, you'd wrap the whole thing in a try block, catch any error, and print a message. It works until the database connection fails, and your catch block swallows the error because you didn't check the type. In Rust, the compiler stops you before you can swallow that error. It forces you to decide what happens when the file is missing, when the JSON is malformed, and when the network is down. You can't ignore failure. You have to name it.

Result: the two-envelope system

Rust doesn't have exceptions. Exceptions let errors jump over functions and skip code, which makes it hard to track where things went wrong. Instead, Rust uses Result<T, E>. Think of Result like a sealed package delivered to your function. The package has two possible states. It either contains the item you ordered, wrapped in Ok(value), or it contains a rejection slip explaining what went wrong, wrapped in Err(error). You can't reach into the package and grab the item without first checking the label. The compiler enforces this check. If you try to use the value without handling the Err case, the code won't compile. This makes error paths explicit. Every function that can fail returns a Result. The caller sees the Result in the return type and knows immediately that they need to handle failure.

The compiler forces you to name the failure. You can't ignore it.

Minimal example: parsing with safety

fn parse_age(input: &str) -> Result<u32, std::num::ParseIntError> {
    // str::parse returns a Result. It might succeed with a number,
    // or fail if the string contains letters.
    input.parse::<u32>()
}

fn main() {
    // We get a Result back. We must handle both cases.
    let age_result = parse_age("42");

    // match forces us to look at both Ok and Err.
    match age_result {
        Ok(age) => println!("Age is {}", age),
        Err(e) => eprintln!("Failed to parse age: {}", e),
    }
}

Match forces the decision. Handle the error or the code doesn't run.

Walkthrough: what the compiler checks

When you call parse_age("42"), the function returns Ok(42). The match expression checks the variant. Since it's Ok, the code runs the first arm and prints the age. If you call parse_age("hello"), the function returns Err(ParseIntError). The match skips the Ok arm and runs the Err arm, printing the error message. At compile time, the compiler checks that match covers all variants of Result. If you forget the Err arm, you get E0004 (non-exhaustive patterns). The compiler refuses to build the program until you acknowledge that failure is possible. This prevents silent crashes. You can't accidentally use a Result as a value. If you write let age = parse_age("42"); without matching, the compiler gives you E0308 (mismatched types) because Result<u32, ...> is not a u32. You have to unwrap or match to get the value out.

The compiler checks the variants. If you miss one, you get E0004. Cover every case.

Recoverable vs unrecoverable

Rust draws a hard line between recoverable and unrecoverable errors. Recoverable errors are expected failures. A file might not exist. A network request might time out. The program can handle these by retrying, using a default, or asking the user. These use Result. Unrecoverable errors indicate a bug. An index is out of bounds. A variable is used after being moved. The program is in an invalid state. Continuing would be dangerous. These use panic!. When a panic happens, the program unwinds the stack and terminates. You can catch panics in tests, but in production, a panic usually means the process dies. The compiler helps you distinguish these. Functions that return Result signal recoverable errors. Functions that don't return Result promise not to panic under normal circumstances. If a function can panic, the documentation should say so.

Treat panic! as a bug report. If the program panics, something is wrong with your logic.

Propagating errors with ?

In real code, you rarely want to handle every error with a match block. Most of the time, you just want to pass the error up to the caller. That's what the ? operator does. It's syntactic sugar for a common pattern. When you write value?, the compiler expands it to check the Result. If it's Ok, the value is extracted. If it's Err, the error is returned immediately from the current function. This turns a nested mess of matches into a flat, readable chain. The ? operator also handles type conversion automatically. If the function returns Result<T, MyError> and you use ? on a Result<T, IoError>, the compiler calls From::from to convert the IoError into MyError. This lets you mix different error types in the same function.

use std::fs;

fn get_user_config() -> Result<String, std::io::Error> {
    // ? operator checks the Result.
    // If Ok, it extracts the value.
    // If Err, it returns the error immediately from this function.
    let contents = fs::read_to_string("config.json")?;
    Ok(contents)
}

fn main() {
    // main can return Result in Rust.
    // This lets us use ? in main too.
    match get_user_config() {
        Ok(config) => println!("Config: {}", config),
        Err(e) => eprintln!("Config error: {}", e),
    }
}

Let the error bubble up. Handle it where you have the context to fix it.

Functional combinators: chaining without nesting

Rust's Result behaves like a container. You can chain operations on it without unwrapping. This is the functional style. Instead of let val = result?; let new_val = transform(val); Ok(new_val), you can write result.map(transform). The map function takes a closure and applies it to the Ok value. If the result is Err, map passes the error through unchanged. This lets you build pipelines. Use and_then when the next step can also fail. and_then takes a closure that returns a Result. If the current result is Ok, the closure runs. If the closure returns Err, the chain stops. If the current result is Err, the closure never runs. This is how you chain multiple failing operations without nesting.

fn process(input: &str) -> Result<i32, std::num::ParseIntError> {
    // map applies a function to the Ok value.
    // If input is "10", this returns Ok(20).
    // If input is "abc", this returns Err(ParseIntError).
    input.parse::<i32>().map(|n| n * 2)
}

fn chain_ops(input: &str) -> Result<i32, std::num::ParseIntError> {
    // and_then chains a function that returns a Result.
    // parse returns Result. double returns Result.
    // If parse fails, double never runs.
    input.parse::<i32>().and_then(|n| {
        if n > 100 {
            Err(std::num::ParseIntError::from(std::num::IntErrorKind::PosOverflow))
        } else {
            Ok(n * 2)
        }
    })
}

The community treats unwrap() as a signal that you are ignoring safety for convenience. Use it in tests where a failure means your test setup is broken. In production code, prefer ? to propagate errors. If you must unwrap, add a comment explaining why the error is impossible. When chaining with and_then, use map_err to transform error types. map_err applies a closure to the Err variant. This is useful for wrapping errors or adding context. Keep combinators short. If the chain gets longer than three links, switch to ? and let bindings. Readability wins over cleverness.

Keep chains short. If it gets hard to read, break it up with let bindings.

Pitfalls and compiler errors

The ? operator requires the error type to match. If your function returns Result<String, MyError> and you try to use ? on a Result<String, OtherError>, the compiler rejects it with E0277 (the trait bound From<OtherError> for MyError is not satisfied). Rust doesn't know how to convert OtherError into MyError. You need to implement From for your error type, or use a crate like thiserror to automate the conversion. Another common trap is shadowing. If you write let result = do_something()?;, the variable result holds the value, not the Result. Beginners sometimes try to match on result later and get confused. The ? consumes the Result and yields the inner value. Once you use ?, the Result is gone. You can't recover the error later. If you need to inspect the error before propagating, use match or if let Err(e) = ....

Check the error type. If the types don't match, the compiler is right.

Decision: when to use what

Use match when you need to handle the error locally and transform it into a different value or action. Use match when the error type contains data you need to inspect, like a specific error code or message. Use the ? operator when you want to propagate the error to the caller. Use ? when the current function doesn't have enough context to recover from the failure. Use ? to keep your code flat and readable instead of nesting match blocks. Use unwrap() only in tests or when the error is provably impossible. Use unwrap_or when you have a sensible default value that makes sense if the operation fails. Use expect() when you want to panic with a custom message that explains why the failure is unexpected. Use panic! when the program is in an invalid state that cannot be recovered from. Use panic! for internal invariants, like an index out of bounds or a duplicate entry in a set that should be unique.

Propagate errors until you reach the code that knows what to do.

Where to go next