Skip to content
beginner Phase 5 · Enums, Option, Result, and Pattern Matching

Result for Fallible Work

Return Ok and Err values so failure cases stay visible in every signature.

45m
0 problems
Topic Progress 0%

Failure Is Part of the Signature, Not a Side Channel

In languages with exceptions, a function's type says nothing about whether it can fail. readConfig(path) and readConfigOrDie(path) have identical signatures; callers discover the difference in production. Rust puts the possibility of failure in the type itself:

enum Result<T, E> {
    Ok(T),
    Err(E),
}

A function that can fail returns Result<T, E>, and every caller sees the failure case in the signature before reading a line of the body:

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

fn read_port(path: &str) -> Result<u16, io::Error> {
    let raw = fs::read_to_string(path)?;   // early return on Err, explained next chapter
    raw.trim().parse()
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}

Two design decisions are visible in that signature. First, the error type E is generic, so the function states exactly which failure vocabulary it speaks — here, the standard library's io::Error. Second, there is no hidden channel: the body cannot "throw" past the return type, so a caller who ignores the failure must do so explicitly, and the compiler will nag until they do. Ignoring a Result produces #[must_use] warnings, and cargo clippy escalates to unused_must_use/let_underscore_must_use errors in strict setups — the opposite of an exception nobody notices being declared.

Error types are a design surface. The standard library gives you io::Error for operating-system failures and Box<dyn Error> when you want a grab-bag that fits anything at the cost of dynamic dispatch and no concrete type. Most libraries settle on a dedicated enum, one variant per failure mode:

#[derive(Debug)]
enum ConfigError {
    Io(io::Error),
    MissingKey(String),
    BadPort { source: std::num::ParseIntError },
}

An error enum is data like any other, and it earns the same guarantees: the compiler knows every variant, so a match over possible failures is exhaustive, and callers can act on MissingKey differently from Io. Carrying the underlying failure as a field (the source above) preserves why the high-level check failed, which is what makes an error message useful at 3 a.m. For interviews, the comparison to state plainly: exceptions carry failure invisibly and cross any boundary; Result carries it visibly and can only cross boundaries you name — that is the entire trade, and Rust takes the visible side.

Errors are also values you format, so the standard Error/Display traits matter as much as the enum's variants:

use std::fmt;

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ConfigError::Io(e) => write!(f, "could not read config: {e}"),
            ConfigError::MissingKey(k) => write!(f, "config is missing required key `{k}`"),
            ConfigError::BadPort { source } => write!(f, "port is not a number: {source}"),
        }
    }
}

impl std::error::Error for ConfigError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            ConfigError::Io(e) => Some(e),
            ConfigError::BadPort { source } => Some(source),
            ConfigError::MissingKey(_) => None,
        }
    }
}

Display is what a user or a log line prints; source() links to the underlying failure so tools can walk the whole chain. Implementing both is a five-minute job that pays for itself the first time a support ticket arrives with a message like "port is not a number: invalid digit found in string" instead of a bare error code.

Choosing which failure mechanism to use is then a small, explicit decision:

Situation Mechanism
expected failure with distinct modes Result<T, MyErrorEnum>
optional value, no complaint attached Option<T>
violated internal invariant, cannot continue panic! (or expect)
programmer misuse of an API contract unsafe / debug assertion

Every row is a deliberate choice the type system records — and none of them happens by accident, which is the point of the whole design.

`?`: Propagation With a Conversion You Can See

Handling every intermediate error by hand produces the nested-match pyramid that gave error handling its reputation. Rust's answer is the ? operator, which reads as "give me the Ok value or return the Err from here" — and quietly performs one more trick: a type conversion.

fn load(path: &str) -> Result<Config, ConfigError> {
    let text = fs::read_to_string(path)?;   // io::Error -> ConfigError via From
    parse(&text)
}

At the ?, the compiler desugars roughly to:

let text = match fs::read_to_string(path) {
    Ok(v) => v,
    Err(e) => return Err(ConfigError::from(e)),   // From conversion, then early return
};

That From call is the key mechanism. ? never blindly re-wraps: it converts the error into the function's declared error type, which requires an actual conversion to exist. The standard library provides the common ones (io::Error -> Box<dyn Error>), and you supply the rest by writing From — manually or with a build-time derive from a crate like thiserror:

impl From<io::Error> for ConfigError {
    fn from(e: io::Error) -> Self { ConfigError::Io(e) }
}

Once that exists, every ? in functions returning Result<_, ConfigError> converts automatically, and the chain stays flat:

fn boot() -> Result<Server, ConfigError> {
    let cfg = load("config.toml")?;
    let addr = cfg.addr.parse()?;       // AddrParseError -> ConfigError, if you wrote From
    Server::bind(addr).map_err(ConfigError::from)
}

The boundary rules are worth memorizing. ? works on Result and on Option — an Option in a Result-returning function propagates None as an early return with the inferred error type, which is why None-propagating helpers usually return Result<_, Infallible> or you convert with ok_or. Mixing them explicitly is done with transpose, which flips Option<Result<T, E>> and Result<Option<T>, E> into each other — the composition pattern this topic closes with. And ? never swallows: whatever propagates still arrives as an Err at some caller who can match on it, log it, translate it, or — with the same operator — pass it up again. Error context is added along the way with map_err, with_context-style helpers from crates such as anyhow, or by constructing your own variant carrying the source, so the final message reads like a story rather than a parse code.

? even reaches main, which is how command-line tools propagate failures to the shell:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let cfg = load("config.toml")?;        // any Error converts into the box
    serve(cfg)?;
    Ok(())
}

Return an Err from main and the runtime prints its Display text to stderr and exits with status 1 — no custom logging required, no process::exit sprinkled through the code. The Box<dyn Error> return is the pragmatic choice for binaries: one concrete error type per crate is a library concern; a tool just needs a message and a non-zero exit code.

The other everyday idiom is lifting an Option into the error path at the exact point where absence stops being acceptable:

let raw = params
    .get("timeout")
    .ok_or(ConfigError::MissingKey("timeout".into()))?;
let timeout: u64 = raw.parse()?;

Line one converts "not present" into a descriptive Err, and from there the normal ? machinery takes over. When a low-level error is too thin to be useful, enrich it on the way up rather than losing it:

fs::read_to_string(&path)
    .map_err(|e| ConfigError::Io(io::Error::new(e.kind(), format!("{path}: {e}"))))?;

The message now names the file that failed — the difference between an error you can act on and an error you have to reproduce to understand.

Panics, Combos, and the `Result<Option<T>>` Shape

Result ships with the same panic accessors as Option, and the same policy applies — with a sharper edge, because I/O and parsing failures are expected, not bugs:

let cfg = load("config.toml").expect("config ships with every deployment");
let port = raw.parse::<u16>().unwrap();   // panics on user input: a real bug in production

expect("reason") remains acceptable when the Err branch truly cannot happen — a file embedded in the binary, a value you just constructed in a test. Parsing user input, reading a socket, or touching a database with unwrap converts ordinary, recoverable failures into process death, and that is the single most common "Rust service crashed in production" story. For the recoverable path, the combinators mirror Option's:

let cfg = load(path)
    .map(|c| c.port)
    .unwrap_or(DEFAULT_PORT);

match load(path) {
    Ok(cfg) => serve(cfg),
    Err(ConfigError::Io(e)) if e.kind() == io::ErrorKind::NotFound => serve(defaults()),
    Err(e) => return Err(e),   // real failures still propagate
}

Notice the guard on Err(ConfigError::Io(e)): an error enum plus pattern matching lets one failure mode take the default path while others keep their severity — something exceptions handle with fragile string matching on messages.

The two enum types compose into the shape that appears constantly in real APIs — "succeeded, but maybe nothing found":

fn find_port(raw: Option<&str>) -> Result<Option<u16>, ParseIntError> {
    match raw {
        Some(s) => s.parse().map(Some),   // Err only on malformed input
        None => Ok(None),                 // field simply absent
    }
}

Read the type aloud and the design is self-documenting: Err means the request was malformed; Ok(None) means the field was absent and defaults apply; Ok(Some(port)) means here is the value. Collapsing those three into one Option or one panic loses exactly the distinctions callers need. The conversions between the worlds complete the toolkit: result.ok() turns Err into None (losing the message — only for genuinely optional work), option.ok_or(err) turns absence into an error, and result.err() drops the success value when you only care about the failure. Choosing between them is choosing whether "nothing here" is a fact or a complaint, and once that choice lives in the type, every caller inherits the answer.

Result's combinator set mirrors Option's with one extra dimension — the error side:

Method Effect
map / map_err transform the success value / the error
and_then chain a Result-returning function (T -> Result<U, E>)
or_else recover with an alternative only on Err
unwrap_or / unwrap_or_else default the success value away
is_ok / is_err cheap boolean checks for control flow

And testing error paths is first-class, because Result compares and matches like any enum:

assert!(matches!(load("missing.toml"), Err(ConfigError::Io(_))));

let err = parse("port = abc").unwrap_err();          // get the Err directly in tests
assert!(err.to_string().contains("not a number"));

unwrap_err() is the mirror of unwrap() and is perfectly at home in tests, where a panic on the unexpected variant is exactly the failure signal you want. The one habit to unlearn is discarding a result in production code: let _ = do_work(); and drop(result) both silence #[must_use], and while they are occasionally the right call — a best-effort cleanup where failure changes nothing — each occurrence deserves a one-line comment saying why the failure is ignorable. Silencing the warning without the comment is how a meaningful Err quietly becomes a println! nobody reads.

Quiz

1. What does the `?` operator do on a `Result` value inside a function returning `Result<_, E>`?

Question 1 options

2. Why is the error type in `Result<T, E>` generic rather than fixed to one library error type?

Question 2 options

3. `let n = input.parse::<u16>().unwrap();` sits in a function handling HTTP requests. What is the main problem?

Question 3 options

4. A lookup should distinguish "field absent" from "field malformed". Which type expresses that?

Question 4 options

Flashcards

Question

What does `Result<T, E>` contain, and how does it differ from exceptions?

Answer

`enum Result<T, E> { Ok(T), Err(E) }` — an ordinary enum. Failure travels in the return value instead of a hidden channel, so the signature declares it, `#[must_use]` warns on ignored results, and no failure can cross a boundary the types don't name.

Question

What exactly does `?` do on `Err`, including the conversion step?

Answer

On `Ok(v)` it yields `v`; on `Err(e)` it returns `Err(From::from(e))` from the enclosing function — converting the error into the function's declared error type via a `From` impl, then early-returning. That's why propagation needs `impl From<LowLevelError> for MyError`.

Question

When are `unwrap`/`expect` acceptable on a `Result`?

Answer

Only when `Err` is a genuine bug — a file you embedded in the binary, a test fixture, a value you just constructed successfully. For I/O, parsing, or user input, `Err` is expected and `unwrap` turns a recoverable case into a panic; propagate with `?` or map to a response instead.

Question

What three outcomes does `Result<Option<T>, E>` encode?

Answer

`Ok(Some(v))` — succeeded with a value. `Ok(None)` — succeeded, nothing found (legitimate absence). `Err(e)` — the operation itself failed. Use it when "not found" and "malformed/failed" must stay distinguishable, e.g. optional config fields with parse errors.

Question

How do `transpose`, `ok`, and `ok_or` move values between `Result` and `Option`?

Answer

`transpose()` flips nesting: `Result<Option<T>, E>` ↔ `Option<Result<T, E>>`. `result.ok()` drops the error and keeps `Option<T>` (only for genuinely optional work — the message is lost). `option.ok_or(err)` turns absence into `Err`, so `?` can propagate a missing value as a failure.

Revision Notes

Key Takeaways

  • 1. `Result` makes failure part of the type: signatures declare it, `#[must_use]` flags ignoring it, and nothing can throw past the return type
  • 2. The error type is a design surface — a dedicated enum with one variant per failure mode enables exhaustive matching and targeted recovery
  • 3. `?` is early-return plus `From` conversion; propagation speed depends on writing those `From` impls once
  • 4. Context belongs on errors as they travel — `map_err` or `with_context`-style helpers turn parse codes into stories
  • 5. `Result<Option<T>, E>` is the canonical three-way shape: success with value, legitimate absence, and real failure

Interview Tips

  • • "How does Rust error handling differ from exceptions?" — visible in the signature, typed, `?`-propagated with conversions, and `must_use`; note the cost too: plumbing error types is manual work
  • • "Explain what `?` desugars to" — `match { Ok(v) => v, Err(e) => return Err(From::from(e)) }`; mention it also works on `Option` in `Result`-returning functions
  • • "When would you `unwrap` in production code?" — essentially never on I/O or input; only on invariants you can state, and always with `expect`'s message
  • • "Model a lookup where missing and malformed differ" — `Result<Option<T>, E>` with the three cases spelled out; shows command of enum composition

Cheat Sheet

Result

Definition: enum Result<T, E> { Ok(T), Err(E) } — failure in the signature, no hidden channel.

Propagation:

fn boot() -> Result<Server, ConfigError> {
    let cfg = load("config.toml")?;        // Err converts via From, returns early
    let addr = cfg.addr.parse()?;          // needs impl From<AddrParseError> for ConfigError
    Server::bind(addr).map_err(ConfigError::Io)
}

? = extract Ok OR return Err(From::from(e)).

Error type design: one enum variant per failure mode; keep the low-level error as a source field for diagnostics.

Combos:

Expression Meaning
Result<Option<T>, E> ok / absent / failed — three-way split
r.transpose() flip Result<Option<..>> ↔ Option<Result<..>>
r.ok() drop the error → Option<T> (message lost)
opt.ok_or(e) absence becomes Err(e) → ?-able
r.map_err(add_context) keep the value path, enrich the error path

Panic policy: expect("why Err is impossible") for invariants; never unwrap on I/O, parsing, or user input.

Pattern-match recovery:

match load(path) {
    Ok(cfg) => serve(cfg),
    Err(ConfigError::Io(e)) if e.kind() == io::ErrorKind::NotFound => serve(defaults()),
    Err(e) => return Err(e),
}

Continue Learning