Modeling Absence and Failure
Option<T> and Result<T, E> keep “not supplied” separate from “supplied but invalid.”
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:
&Settingsborrows 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 typedPortErrorenum? - 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
0be 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.