Rust Basics #6 Error Handling: Result, the ? Operator, and the Boundary Between panic and unwrap

5 min read

Rust has no try/catch, because it has no concept of exceptions at all. Instead, following the same principle as Option in the last part, the fact that something can fail is put into the return type, and the compiler forces you to handle it. It looks tedious at first, but the result is code where “under what circumstances can this function fail” is fully visible from signatures alone.

Result: success or failure #

Result definition
enum Result<T, E> {
    Ok(T),   // success — carries the result
    Err(E),  // failure — carries the error
}

Functions that can fail — file reads, network requests, parsing — all return Result. The parse we have used since Part 2 is one of them.

src/main.rs
let input = "42";
let n: i32 = match input.parse() {
    Ok(value) => value,
    Err(e) => {
        println!("not a number: {e}");
        return;
    }
};

The difference from exceptions shows right here. With exceptions, forgetting to handle still compiles, and something blows up somewhere at runtime. A Result is not an i32 but a Result<i32, ParseIntError> — a different type — so the value cannot be used before the failure possibility is peeled off. Forgetting to handle it is impossible at the compile stage.

The ? operator: pass errors upward #

Write match around every call and code turns into a staircase. Most real-world error handling is “don’t handle it here, hand it to the caller” — and the ? operator compresses that propagation into one character.

src/main.rs
use std::fs;

fn read_config() -> Result<Config, ConfigError> {
    let text = fs::read_to_string("config.toml")?; // on Err, return immediately
    let config = parse_config(&text)?;             // on Err, return immediately
    Ok(config)
}

If the result is Ok, ? takes out the inner value and continues; if it is Err, it returns that error from the function immediately. Unrolled into match, the function above is a dozen-plus lines; two ?s mean the same thing. There is one condition: the function’s return type must be Result, because the place the error is handed to has to be declared in the signature. Use ? in an ordinary function and you get a compile error (E0277) saying the function does not return a Result — with a help line suggesting the change.

Propagation can continue all the way to main. Since main may also return a Result, small programs commonly settle on this shape:

src/main.rs
use std::error::Error;

fn main() -> Result<(), Box<dyn Error>> {
    let config = read_config()?;
    run(config)?;
    Ok(())
}

Box<dyn Error> is the pragmatic type that means “any kind of error will do” (what dyn really is comes in Part 8, with traits). It is the common choice at the entry point of a program where different error types mix, and if an error climbs all the way to main, the process prints the message and exits with a failure code.

unwrap and expect: terms of use for the shortcut #

src/main.rs
let n: i32 = "42".parse().unwrap();                    // panics on Err
let n: i32 = "42".parse().expect("failed to parse port number"); // unwrap with a message

unwrap abandons failure handling and chooses “if this fails, just die.” It looks irresponsible, but it has legitimate places.

  • Examples, prototypes, tests: when error handling is not the point of the code. The earlier parts of this course did exactly this.
  • When failure means the program itself is buggy: like fetching a key you inserted one line above — if that fails, the logic is broken. Here expect("key inserted just above is missing") is the better form, because it records the reasoning.

By contrast, unwrap at points that can fail during normal operation — user input, files, the network — is a time bomb. One day a single malformed input takes the whole process down. Those places get ? for propagation or match for recovery.

panic: not recovery but termination #

panic! is the mechanism that stops the program on the spot — an unwrap failure is exactly this, and so was the out-of-bounds array index in Part 2. Rust’s dividing line is clear.

  • Expected failures (missing file, bad input, dropped connection) → express with Result, and handling is enforced.
  • States that must never happen (violated invariants, logically impossible branches) → stop immediately with panic!, because dying is safer than continuing in a corrupted state.

If you are writing a library, the line matters even more. Turning a failure the caller could recover from into a panic is a design that takes the choice away from the caller.

Summary #

  • Rust has no exceptions. Failure lives in the Result<T, E> return type, and the value cannot be used until it is handled.
  • The ? operator extracts on Ok and returns immediately on Err. It works only inside functions that return Result.
  • Small programs settle on main() -> Result<(), Box<dyn Error>> as the practical default.
  • unwrap/expect are legitimate in examples and tests, or where failure means a bug. Input, files, and the network get ? and match.
  • Expected failures are Result; impossible states are panic. That boundary is the axis of Rust error design.
  • Next: the body of practical code — collections and iterators. Vec, String, HashMap, and the chain style that replaces for loops.
X