How to Use Result<T, E> for Error Handling in Rust

Use Result<T, E> to handle recoverable errors by returning Ok for success and Err for failure, allowing your program to continue running.

When failure is just another value

You write a function to parse a port number from a configuration file. The file contains "8080". The function returns 8080. The file contains "abc". In many languages, the program crashes or throws an exception that bubbles up until some distant handler catches it. Rust takes a different path. The function signature declares that failure is a normal outcome. The caller must decide what to do. There is no silent crash. There is no hidden exception. The type system forces you to handle the error right where it happens.

Result as a sealed envelope

Result<T, E> is an enumeration with two variants: Ok(T) and Err(E). Think of it like a sealed envelope delivered by a courier. The envelope either contains the document you requested, wrapped in Ok, or it contains a rejection notice, wrapped in Err. The courier never just drops the envelope on the ground and runs away. You always receive something. You open the envelope and check which variant you got. If it's Ok, you extract the value. If it's Err, you read the error information.

The type parameters T and E let you specify exactly what kind of success value and what kind of error information you expect. This precision is what makes error handling in Rust explicit and composable. The compiler checks that you handle both cases. You cannot accidentally ignore an error. The type signature is the contract. If it says Result, handle the error.

Minimal example

/// Returns the parsed port number or an error string.
fn parse_port(input: &str) -> Result<u16, String> {
    // Attempt to parse the string as a u16.
    // parse returns a Result<u16, ParseIntError>.
    match input.parse::<u16>() {
        Ok(port) => Ok(port),
        Err(e) => Err(format!("Invalid port: {}", e)),
    }
}

fn main() {
    // Call the function and handle both outcomes.
    match parse_port("8080") {
        Ok(port) => println!("Using port {}", port),
        Err(msg) => eprintln!("Configuration error: {}", msg),
    }
}

What happens under the hood

When you call parse_port, the compiler checks the return type. It sees Result<u16, String>. If you try to use the result as a u16 directly, the compiler rejects you with E0308 (mismatched types). You cannot treat a Result as the value inside it. You must unwrap it or match on it. This check happens at compile time.

At runtime, Result has zero overhead compared to a C union. It is just a tagged value. The match statement branches based on the tag. If the variant is Ok, the code extracts the value. If it is Err, the code runs the error branch. No exceptions, no stack unwinding unless you panic. The control flow is explicit. The compiler knows exactly which path the code can take. This allows optimizations that are impossible with exception-based models.

Propagating errors with ?

Real code involves chaining operations. Every step might fail. Writing nested matches becomes unreadable. Rust provides the ? operator to propagate errors cleanly. The ? operator checks the Result. If it is Ok, it extracts the value. If it is Err, it returns the error from the current function immediately.

use std::fs;

/// Reads a file and returns its contents as a string.
/// Returns an error if the file cannot be opened or read.
fn read_config(path: &str) -> Result<String, std::io::Error> {
    // Open the file. The ? operator checks the Result.
    // If Err, it returns early from this function.
    // If Ok, it extracts the File value.
    let mut file = fs::File::open(path)?;

    let mut contents = String::new();
    // Read the file contents. Propagate any read errors.
    file.read_to_string(&mut contents)?;

    // Return the contents wrapped in Ok.
    Ok(contents)
}

fn main() {
    // Handle the final result in main.
    match read_config("config.txt") {
        Ok(text) => println!("Config loaded: {}", text),
        Err(e) => eprintln!("Failed to load config: {}", e),
    }
}

The community convention is to use ? for propagation and match or if let for handling. Keep error handling close to the edge of your application. Library functions should propagate errors using ?. Binary main functions or top-level handlers decide how to report the error to the user. This keeps the logic clean and separates concerns. Let the errors bubble. Handle them where you have context.

Chaining transformations

You often need to transform the success value or chain another fallible operation. Result provides methods like map and and_then. map applies a function to the Ok value. and_then applies a function that returns a Result, allowing you to chain fallible operations.

/// Parses a port and validates it is within a safe range.
fn parse_and_validate(input: &str) -> Result<u16, String> {
    // Parse the input.
    // map transforms the Ok value. If Err, it passes through unchanged.
    input.parse::<u16>()
        .map_err(|e| format!("Parse failed: {}", e))
        .and_then(|port| {
            // and_then takes a closure that returns a Result.
            // This allows chaining fallible operations.
            if port > 1024 {
                Ok(port)
            } else {
                Err("Port must be greater than 1024".to_string())
            }
        })
}

Method chaining is idiomatic for transformations. Use map for infallible transforms. Use and_then for fallible ones. Use map_err to transform the error type. This style keeps the code linear and readable.

Adding context to errors

When errors propagate, context gets lost. The ? operator converts the error but does not add context. If you need to add information, use map_err or a helper crate.

fn load_config() -> Result<String, String> {
    // map_err adds context to the error before propagating.
    fs::read_to_string("config.txt")
        .map_err(|e| format!("Failed to read config.txt: {}", e))
}

In larger projects, developers often use error handling crates. thiserror helps define custom error types with derives. anyhow provides a flexible error type for application code. For learning the basics, standard library Result and map_err are sufficient. The key insight is that errors should carry enough information to diagnose the problem. A bare "Error" message is useless. Always add context.

Special case for main

The main function has a special rule. It can return Result<(), E> where E implements Debug. If main returns an Err, the runtime prints the debug representation and exits with a non-zero status. This saves you from writing a match in main for simple scripts.

fn main() -> Result<(), std::io::Error> {
    // If this fails, main returns Err and the program exits.
    let _ = fs::read_to_string("missing.txt")?;
    Ok(())
}

Pitfalls and compiler errors

Unwrapping blindly is the most common mistake. unwrap() panics on Err. The compiler will not stop you, but the runtime will crash. Use expect() with a message if you must unwrap, to explain why you thought it was safe. Never unwrap in production code without a reason. The panic message is a failure of communication.

The ? operator relies on the From trait to convert error types. If the function returns Result<T, MyError> and you use ? on a Result<T, IoError>, the compiler rejects you with E0277 (trait bound not satisfied) unless MyError implements From<IoError>. You need to convert the error types explicitly or implement the conversion. This ensures that error types are compatible as they move up the call stack.

Result values are consumed by match and ?. You cannot use the same Result twice. If you try, you get E0382 (use of moved value). Clone the result or restructure the code if you need to inspect it multiple times. This consumption model prevents accidental reuse of error states.

Sometimes you want a default value instead of propagating. unwrap_or provides a fallback.

let port = parse_port("abc").unwrap_or(8080);

unwrap_or swallows the error. Use this only when the default is truly safe and the error is expected noise. If the error indicates a configuration problem, propagating it is better than hiding it behind a default. Swallowing errors makes debugging harder.

Decision matrix

Use Result<T, E> when a function can fail in a recoverable way and the caller needs to decide how to proceed.

Use Option<T> when a value might be absent but absence is not an error condition.

Use panic! when the program reaches an unrecoverable state and continuing would be unsafe or nonsensical.

Use unwrap() only in tests or throwaway code where you have verified the value exists and want to fail fast if your assumption is wrong.

Use expect() instead of unwrap() in production code where you must unwrap, providing a message that explains the invariant you are relying on.

Use ? to propagate errors up the call stack when the current function cannot handle the error meaningfully.

Use match or if let to handle errors locally when you can recover, provide a default, or transform the error into a more specific type.

Pick the tool that matches the failure mode. Errors are data, not disasters.

Where to go next