How does Result work in Rust

Result is an enum with Ok and Err variants used to handle recoverable errors in Rust without crashing.

How Result Works in Rust

You are building a command-line tool that reads a configuration file. The file might not exist. The format might be malformed. The user might have typed a typo in the path. Your code needs to do something different for each case, and it needs to tell the caller exactly what went wrong without crashing the whole program. In languages that use exceptions, the error handling is scattered and invisible. In Rust, errors are values. You pass them around, inspect them, and decide how to recover. The type that makes this possible is Result.

The sealed envelope

Think of Result like a sealed envelope from a delivery service. The envelope guarantees it contains one of two things: the package you ordered, or a rejection slip explaining why the delivery failed. You cannot use the package until you open the envelope and verify what is inside. Rust forces you to open every envelope. The compiler will not let you assume the package arrived. If you try to use the result without checking, the code does not compile.

Result is an enum defined in the standard library with two variants: Ok(T) and Err(E). The T stands for the type of value you get on success. The E stands for the type of error you get on failure. When a function returns Result, it is telling the caller: "I might succeed with a value of type T, or I might fail with an error of type E. You must handle both."

Minimal example

Here is a function that adds two numbers, but refuses to work with negative inputs. It returns a Result so the caller can decide what to do when the inputs are invalid.

/// Returns Ok with the sum, or Err if inputs are negative.
fn add_positive(a: i32, b: i32) -> Result<i32, String> {
    if a < 0 || b < 0 {
        // Return the error variant with a descriptive message
        return Err("Inputs must be positive".to_string());
    }
    // Return the success variant with the computed value
    Ok(a + b)
}

fn main() {
    // Match on the Result to extract the value or handle the error
    match add_positive(5, 10) {
        Ok(sum) => println!("Result: {}", sum),
        Err(e) => println!("Failed: {}", e),
    }
}

The match expression is the standard way to open the envelope. It checks which variant is present and binds the inner value to a variable. If you forget to handle Err, the compiler rejects you with an exhaustiveness error. You cannot ignore the failure case.

Walking through the types

When you see Result<i32, String>, the first type parameter is the success type and the second is the error type. This ordering is fixed. Ok always carries the first type, Err always carries the second.

If you try to assign a Result to a variable of the inner type, the compiler stops you. This is a common stumbling block for newcomers. You cannot write let sum = add_positive(5, 10); and expect sum to be an i32. The compiler rejects this with E0308 (mismatched types) because Result<i32, String> is not the same as i32. You must extract the value using match, if let, or one of the helper methods.

The error type E can be anything. It is often a string for simple cases, but in real code it is usually a structured type. The standard library provides std::io::Error for file operations, std::num::ParseIntError for parsing, and many others. You can also define your own error types. The flexibility of E allows you to carry context. Instead of just saying "error", you can return an error that includes the filename, the line number, and the invalid bytes.

Rust forces you to open every envelope. You cannot pretend the package arrived if the rejection slip is inside.

Chaining with combinators

You do not always need match. Result provides a set of methods called combinators that let you chain operations without nesting. These methods return new Result values, allowing you to build a pipeline of fallible steps.

/// Chains parsing and validation without nested matches.
fn process_input(input: &str) -> Result<u32, String> {
    input
        .trim()
        .parse::<u32>()
        // Convert the parse error to our string error type
        .map_err(|e| format!("Parse failed: {}", e))
        // Chain a validation step that also returns Result
        .and_then(|n| {
            if n > 100 {
                Err("Number too large".to_string())
            } else {
                Ok(n)
            }
        })
}

fn main() {
    match process_input(" 42 ") {
        Ok(n) => println!("Valid number: {}", n),
        Err(e) => println!("Error: {}", e),
    }
}

The map method transforms the success value. If the result is Ok, map applies the closure to the value and wraps it back in Ok. If the result is Err, map leaves the error untouched. The map_err method does the same for the error variant. This is useful when you need to convert an error type to match the function signature.

The and_then method chains a function that returns a Result. It is the fallible version of map. If the result is Ok, and_then calls the closure with the value and returns the new result. If the result is Err, it propagates the error. This avoids the "pyramid of doom" where nested matches make the success path hard to read.

Chaining with and_then is idiomatic for sequences of fallible operations. It reads like a pipeline. The community prefers this over deep nesting of match blocks because it keeps the success path linear.

Propagating errors with ?

In realistic code, functions often call other functions that return Result. If you cannot handle the error locally, you want to pass it up to the caller. Writing match for every call is verbose. Rust provides the ? operator to propagate errors automatically.

use std::fs;
use std::io;

/// Reads a config file and returns the content, or an IO error.
fn load_config(path: &str) -> Result<String, io::Error> {
    // read_to_string returns Result<String, io::Error>
    // The ? operator checks the result and propagates errors
    fs::read_to_string(path)
}

/// Main function that returns Result to let the runtime handle errors.
fn main() -> Result<(), io::Error> {
    // If load_config fails, main returns early with the error
    let content = load_config("config.txt")?;
    println!("Config loaded: {} bytes", content.len());
    Ok(())
}

The ? operator works like this: if the result is Ok, it unwraps the value. If the result is Err, it returns the error from the current function immediately. This requires the function to return a Result with a compatible error type. The ? operator is the community standard for error propagation. It replaces boilerplate match statements and keeps the code focused on the happy path.

Convention dictates using ? for propagation and match or if let only when you need to transform the error, log specific details, or recover with a fallback value.

Unifying errors with From and Box

When you call multiple functions that return different error types, you face a type mismatch. You cannot return io::Error from a function that promises to return std::num::ParseIntError. The ? operator relies on the From trait to convert error types. When you use ? on a Result<T, E1> inside a function returning Result<T, E2>, the compiler looks for an implementation of From<E1> for E2. If it exists, the error is converted automatically.

A common pattern is to box errors behind a trait object. Box<dyn std::error::Error> erases the concrete type and allows any error that implements the Error trait to be returned. This is useful in top-level functions where you just want to run a sequence of operations and print any error that occurs.

use std::error::Error;
use std::fs;

/// Uses a boxed trait object to unify different error types.
fn run() -> Result<(), Box<dyn Error>> {
    // fs::read_to_string returns io::Error
    // io::Error implements Error, so ? converts it to Box<dyn Error>
    let content = fs::read_to_string("data.txt")?;
    
    // parse returns ParseIntError
    // ParseIntError also implements Error, so ? works here too
    let number: i32 = content.trim().parse()?;
    
    println!("Parsed: {}", number);
    Ok(())
}

fn main() {
    // Handle the boxed error at the top level
    if let Err(e) = run() {
        eprintln!("Failed: {}", e);
    }
}

The standard library implements From<E> for Box<dyn Error> for any E that implements Error. This makes Box<dyn Error> a universal sink for errors. Returning Result<(), Box<dyn Error>> is a convention for quick scripts and main functions. It avoids defining a custom error enum when you just need to run a sequence of operations. For library code, define a custom error enum instead to keep the API precise.

Pitfalls and compiler errors

A common trap is mixing error types without conversion. If a function returns Result<String, String>, you cannot use ? to propagate a Result<String, io::Error> because the error types differ. The compiler rejects this with E0277 (trait bound not satisfied) because the From trait is missing to convert io::Error to String. You must map the error or use a unified error type.

Another trap is unwrap(). Calling unwrap() on an Err panics the thread. This turns a recoverable error into a crash. Use unwrap() only in tests or when the error is impossible by logic, and even then, expect("reason") is preferred because it leaves a message in the panic log. The message helps you debug why the assumption was wrong.

Shadowing is a subtle issue. If you write let result = some_function(); and then let result = result.unwrap();, you have shadowed the Result with the inner value. This is fine if you handled the error, but it can hide mistakes. If you accidentally call unwrap() on a value that is already unwrapped, the compiler catches it because the type no longer has an unwrap method. Trust the borrow checker and the type system here. They prevent you from using a value before checking it.

Treat unwrap() as a confession that you have not thought through the error case yet. Replace it before you ship.

When to use Result

Use Result<T, E> when an operation can fail in a recoverable way and you need to distinguish between success and failure. Use Option<T> when the absence of a value is not an error, such as a missing optional field or a search that finds nothing. Use panic! only for programming bugs that should never happen in production, like an invalid state invariant or an out-of-bounds index in a safe API. Use a custom error enum when you need to group multiple error variants and provide context, rather than returning raw strings or library errors. Use the ? operator to propagate errors up the call stack when the current function cannot meaningfully handle the failure. Use match or if let when you need to transform the error, log specific details, or recover with a fallback value.

Errors are data. Model them explicitly, and your code becomes a map of what can go wrong and how you handle it.

Where to go next