Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Modeling Absence and Failure

Option<T> and Result<T, E> keep “not supplied” separate from “supplied but invalid.”

Standard library 1.91.0Option · Result · transpose · ?Core representation

What makes this API worth studying?

An elegant library API makes the caller’s code easy to read and difficult to misuse. Its names are predictable. Ordinary work needs little ceremony. Valid inputs are accepted without needless conversions. Errors carry useful information. Most importantly, types prevent mistakes without constantly getting in the caller’s way.

This chapter begins with Option and Result because they demonstrate that balance in the standard library. They are small types with familiar names, but they let an API state two important facts precisely:

  • a value may be absent as part of normal operation;
  • an operation may fail for a reason the caller can inspect.

A less careful API often collapses both cases into a sentinel such as 0, an empty string, or null. That may look simpler for the provider, but it pushes ambiguity onto every consumer.

The consumer problem

Suppose a program can receive its port from two places. A value in application settings wins; otherwise an environment variable is tried. The environment variable itself may be absent, and if it is present, it may not contain a valid u16.

Those are two different problems:

  • absence: no value was supplied;
  • failure: a value was supplied, but parsing failed.

Rust gives each problem its own type: Option<T> for ordinary absence and Result<T, E> for an operation that can explain why it failed.

The design target is consumer code that reads like the policy:

parse the environment value if one exists
    → stop if parsing fails
    → otherwise prefer the configured port
    → otherwise use the parsed environment port
    → otherwise report that no port exists

Start with the consumer

The central function is small enough to read as a pipeline:

#![allow(unused)]
fn main() {
#[derive(Debug)]
struct Settings {
    port: Option<u16>,
}

fn selected_port(
    settings: &Settings,
    environment: Option<&str>,
) -> Result<u16, String> {
    let environment_port = environment
        .map(str::parse::<u16>)
        .transpose()
        .map_err(|error| format!("invalid PORT: {error}"))?;

    settings
        .port
        .or(environment_port)
        .ok_or_else(|| "no port was configured".to_owned())
}
}

For Settings { port: None } and an environment value of "8080", the observed stdout is:

Selected port: 8080

The important boundary cases are:

environment = Some("not-a-port")  → Err("invalid PORT: ...")
settings.port = None, environment = None
                                  → Err("no port was configured")
settings.port = Some(3000)         → Ok(3000), regardless of the fallback

Read the signature first

#![allow(unused)]
fn main() {
fn selected_port(
    settings: &Settings,
    environment: Option<&str>,
) -> Result<u16, String>
}

Before inspecting the body, the signature already describes the contract:

  • &Settings borrows configuration; the function neither consumes nor mutates it.
  • Option<&str> says the environment value may be absent and, when present, is only borrowed for this call.
  • Result<u16, String> says success produces an owned port number while failure produces an explanation.

Design note

Start an API review at the signature. In Rust, ownership, absence, and failure are part of the caller-visible behavior.

Follow the types, one step at a time

The environment starts as:

#![allow(unused)]
fn main() {
Option<&str>
}

Calling map applies the parser only when a string exists:

#![allow(unused)]
fn main() {
let parsed = environment.map(str::parse::<u16>);
}

The resulting type is not Option<u16>. Parsing can fail, so the real type is:

#![allow(unused)]
fn main() {
Option<Result<u16, ParseIntError>>
}

That nesting is honest, but awkward for a function that wants to use ? on parsing errors. transpose swaps the wrappers:

#![allow(unused)]
fn main() {
let parsed = parsed.transpose();
}

Now the type is:

#![allow(unused)]
fn main() {
Result<Option<u16>, ParseIntError>
}

The transformation can be pictured directly:

Some(Ok(8080))  ──transpose──► Ok(Some(8080))
Some(Err(err))  ──transpose──► Err(err)
None            ──transpose──► Ok(None)

Failure is now on the outside, where ? can return it early. Absence remains inside as a successful None.

Read the standard-library implementation

The public signature of Option::transpose is specialized for this exact shape:

#![allow(unused)]
fn main() {
impl<T, E> Option<Result<T, E>> {
    pub const fn transpose(self) -> Result<Option<T>, E> {
        // ...
    }
}
}

Its implementation is essentially the truth table above:

#![allow(unused)]
fn main() {
match self {
    Some(Ok(value)) => Ok(Some(value)),
    Some(Err(error)) => Err(error),
    None => Ok(None),
}
}

There is no hidden runtime system. The elegant consumer call is powered by a method available only when the compiler knows the inner type is a Result.

On this machine, Neovim’s gd leads to the installed standard-library source:

$(rustc --print sysroot)/lib/rustlib/src/rust/library/core/src/option.rs

Look for impl<T, E> Option<Result<T, E>> and pub const fn transpose.

What ? contributes

After transpose and map_err, this expression has type:

#![allow(unused)]
fn main() {
Result<Option<u16>, String>
}

The trailing ? means:

Ok(value)  → continue with value
Err(error) → return Err(error) from selected_port

Conceptually, this code:

#![allow(unused)]
fn main() {
let environment_port = expression?;
}

resembles:

#![allow(unused)]
fn main() {
let environment_port = match expression {
    Ok(value) => value,
    Err(error) => return Err(error),
};
}

The real language mechanism is more general and supports compatible residual conversion, but this expansion is the right model for this example.

Collapse absence only at the boundary

After parsing, both sources have the same type:

#![allow(unused)]
fn main() {
settings.port      // Option<u16>
environment_port   // Option<u16>
}

The remaining expression says: prefer the setting, fall back to the environment, and only then turn total absence into an error.

#![allow(unused)]
fn main() {
settings
    .port
    .or(environment_port)
    .ok_or_else(|| "no port was configured".to_owned())
}

Keeping Option until the final boundary preserves the useful distinction between “there is still another source to try” and “every source is exhausted.”

The tempting alternative

A sentinel-based API might return 0 when no port exists or silently ignore an invalid environment variable:

#![allow(unused)]
fn main() {
fn selected_port(settings: &Settings, environment: &str) -> u16
}

That signature is shorter, but the caller cannot tell whether 0 is data, absence, or failure. The convention exists only in prose.

An explicit match implementation is perfectly valid:

#![allow(unused)]
fn main() {
let environment_port = match environment {
    Some(text) => match text.parse::<u16>() {
        Ok(port) => Some(port),
        Err(error) => return Err(format!("invalid PORT: {error}")),
    },
    None => None,
};
}

Use the explicit form when each branch does substantial work. Prefer the combinator form when each step is a small transformation whose types tell the story.

Stress the representation

These changed inputs expose the design boundaries.

Invalid supplied value:

#![allow(unused)]
fn main() {
selected_port(&settings, Some("not-a-port"))
}

No value anywhere:

#![allow(unused)]
fn main() {
selected_port(&settings, None)
}

Setting wins over environment:

#![allow(unused)]
fn main() {
let settings = Settings { port: Some(3000) };
selected_port(&settings, Some("8080"))
}

Design questions

  • Should the public error really be a String, or should this API expose a typed PortError enum?
  • Would environment: Option<&str> still be appropriate if the function had to store the string after returning?
  • At what point does a combinator pipeline become less readable than match?
  • Should port 0 be accepted as data or rejected as a domain validation error?

Guarantees, runtime boundaries, and cost

  • Compile-time guarantee: absence and failure cannot be silently confused; each branch is represented in the return type.
  • Runtime boundary: supplied text still needs parsing, and the application decides when ordinary absence becomes an error.
  • Tradeoff: nested Option<Result<...>> values can be cognitively expensive; combinators help only while their transformations remain easy to name.

Takeaway

Option and Result are not just containers. They preserve meaning while an API transforms data: absence can remain ordinary until the precise boundary where it becomes an error.

Source trail