3 min read
On this page

Result & the Question Mark

Rust does not have exceptions. There is no try-catch, no invisible control flow, no wondering whether a function might throw. Instead, operations that can fail return Result<T, E>. The ? operator propagates errors up the call stack cleanly. This approach is more explicit, more composable, and produces more reliable software than exception-based error handling.

Result<T, E> Everywhere

Result is an enum with two variants:

enum Result<T, E> {
    Ok(T),    // success, carrying the value
    Err(E),   // failure, carrying the error
}

Every fallible operation in the standard library returns Result:

use std::fs;
use std::net::TcpListener;

fn main() {
    // File I/O returns Result<String, io::Error>
    let contents = fs::read_to_string("/etc/hostname");
    match contents {
        Ok(text) => println!("Hostname: {}", text.trim()),
        Err(e) => println!("Cannot read hostname: {}", e),
    }

    // Parsing returns Result<T, ParseIntError>
    let num: Result<i32, _> = "42".parse();
    match num {
        Ok(n) => println!("Parsed: {}", n),
        Err(e) => println!("Parse error: {}", e),
    }

    // Network operations return Result
    match TcpListener::bind("127.0.0.1:0") {
        Ok(listener) => println!("Listening on {:?}", listener.local_addr().unwrap()),
        Err(e) => println!("Bind failed: {}", e),
    }
}
Hostname: myhost
Parsed: 42
Listening on 127.0.0.1:54321

The ? Operator

Matching on every Result gets verbose fast. The ? operator propagates errors automatically — if the value is Ok, it unwraps it; if it is Err, it returns the error from the current function:

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

fn read_username() -> Result<String, io::Error> {
    let contents = fs::read_to_string("/etc/passwd")?;  // returns Err if this fails
    let first_line = contents.lines().next().unwrap_or("");
    let username = first_line.split(':').next().unwrap_or("unknown");
    Ok(username.to_string())
}

fn main() {
    match read_username() {
        Ok(name) => println!("First user: {}", name),
        Err(e) => println!("Error: {}", e),
    }
}

Without ?, you would write:

fn read_username_verbose() -> Result<String, io::Error> {
    let contents = match fs::read_to_string("/etc/passwd") {
        Ok(c) => c,
        Err(e) => return Err(e),
    };
    let first_line = contents.lines().next().unwrap_or("");
    let username = first_line.split(':').next().unwrap_or("unknown");
    Ok(username.to_string())
}

? replaces four lines with one. It composes. You can chain multiple fallible operations:

use std::fs;
use std::io;
use std::path::Path;

fn load_config(path: &Path) -> Result<String, io::Error> {
    let raw = fs::read_to_string(path)?;
    let trimmed = raw.trim().to_string();
    Ok(trimmed)
}

fn setup_app() -> Result<(), io::Error> {
    let config = load_config(Path::new("/etc/myapp/config.toml"))?;
    let port = load_config(Path::new("/etc/myapp/port"))?;
    println!("Config: {}, Port: {}", config, port);
    Ok(())
}

Each ? is an early return on error. The happy path reads top to bottom without indentation.

Mapping Errors with map_err

When the error types do not match, map_err converts one error type to another:

use std::fs;
use std::num::ParseIntError;

#[derive(Debug)]
enum AppError {
    Io(std::io::Error),
    Parse(ParseIntError),
    Config(String),
}

fn read_port() -> Result<u16, AppError> {
    let contents = fs::read_to_string("/etc/myapp/port")
        .map_err(AppError::Io)?;

    let port = contents.trim().parse::<u16>()
        .map_err(AppError::Parse)?;

    if port < 1024 {
        return Err(AppError::Config(format!("port {} is privileged", port)));
    }

    Ok(port)
}

fn main() {
    match read_port() {
        Ok(port) => println!("Using port {}", port),
        Err(e) => println!("Error: {:?}", e),
    }
}
Error: Io(Os { code: 2, kind: NotFound, message: "No such file or directory" })

map_err converts the error type without affecting the success case.

Chaining with and_then

and_then chains operations where each step can fail:

fn parse_and_double(input: &str) -> Result<i64, String> {
    input
        .trim()
        .parse::<i64>()
        .map_err(|e| format!("parse error: {}", e))
        .and_then(|n| {
            n.checked_mul(2)
                .ok_or_else(|| format!("overflow when doubling {}", n))
        })
}

fn main() {
    let inputs = ["21", "abc", "4611686018427387904", "  42  "];
    for input in inputs {
        match parse_and_double(input) {
            Ok(result) => println!("'{}' -> {}", input.trim(), result),
            Err(e) => println!("'{}' -> error: {}", input.trim(), e),
        }
    }
}
'21' -> 42
'abc' -> error: parse error: invalid digit found in string
'4611686018427387904' -> error: overflow when doubling 4611686018427387904
'42' -> 84

unwrap & expect

unwrap() extracts the value from Ok or panics on Err. expect() does the same but with a custom panic message.

fn main() {
    // Fine in tests and prototypes:
    let port: u16 = "8080".parse().unwrap();
    println!("Port: {}", port);

    // Better — the message tells you what went wrong:
    let port: u16 = "8080".parse()
        .expect("PORT must be a valid u16");
    println!("Port: {}", port);
}
Port: 8080
Port: 8080

When unwrap is acceptable:

  • Tests — a panic is a test failure, which is what you want
  • Prototyping — you are iterating fast and will handle errors later
  • When you have already validated the input and the error is logically impossible

When unwrap will ruin your day:

  • Production code handling user input
  • Anything touching the filesystem or network
  • Parsing external data
// This will crash your production server at 3 AM:
fn handle_request(body: &str) {
    let value: serde_json::Value = serde_json::from_str(body).unwrap();
    // If the body is malformed, the whole server panics
    println!("{}", value);
}

// This keeps your server running:
fn handle_request_safely(body: &str) -> Result<(), String> {
    let value: serde_json::Value = serde_json::from_str(body)
        .map_err(|e| format!("invalid JSON: {}", e))?;
    println!("{}", value);
    Ok(())
}

Using ? in main

main can return a Result:

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

fn main() -> Result<(), io::Error> {
    let config = fs::read_to_string("config.toml")?;
    let port: u16 = config.trim().parse()
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
    println!("Starting on port {}", port);
    Ok(())
}

When main returns Err, the program exits with a non-zero status code and prints the error using its Debug implementation.

Collecting Results

When you have a collection of results, collect can gather them into a single Result:

fn main() {
    let inputs = vec!["1", "2", "three", "4"];

    // Collect into Result<Vec<i32>, _> — stops at first error:
    let result: Result<Vec<i32>, _> = inputs.iter()
        .map(|s| s.parse::<i32>())
        .collect();

    match result {
        Ok(numbers) => println!("All parsed: {:?}", numbers),
        Err(e) => println!("Failed: {}", e),
    }

    // Partition into successes and failures:
    let (successes, failures): (Vec<_>, Vec<_>) = inputs.iter()
        .map(|s| s.parse::<i32>())
        .partition(Result::is_ok);

    let numbers: Vec<i32> = successes.into_iter().map(Result::unwrap).collect();
    println!("Parsed {} values, {} failed", numbers.len(), failures.len());
}
Failed: invalid digit found in string
Parsed 3 values, 1 failed

Common Pitfalls

  • Using unwrap in library code. Libraries should never panic on behalf of their callers. Always return Result or Option.
  • Discarding error context. file.read()?.parse()? loses information about which step failed. Add context with map_err.
  • Returning String as the error type. It works but you lose the ability to programmatically match on error kinds. Use proper error types or anyhow for applications.
  • Ignoring Result return values. The compiler warns about unused Result values. Do not suppress the warning — handle the error.
  • Mixing ? with unwrap in the same function. Pick one style. If the function returns Result, use ?. If it is a test, unwrap is fine.
  • Not realizing ? performs From conversion. If your function returns Result<T, MyError> and you use ? on a Result<T, io::Error>, Rust will call MyError::from(io::Error) automatically. Implement From to enable this.

Key Takeaways

  • Result<T, E> makes errors visible in function signatures. No hidden throws.
  • The ? operator propagates errors concisely. It replaces verbose match statements and keeps the happy path readable.
  • map_err converts error types. and_then chains fallible operations.
  • unwrap and expect are for tests and prototypes. In production, handle errors explicitly.
  • ? automatically calls From for error type conversion, making it composable across different error types.
  • Errors are values, not exceptions. You can store them, transform them, collect them, and return them.