The Rust Engineering Handbook / Chapter 15
Option, Result, Divergence, and Control-Flow Composition
Compose absence, recoverable failure, traversal breaks, returns, and divergence without erasing their contracts.
Keep the import’s exits distinct
A ledger file contains three lines: one valid entry, one blank separator, and one amount above the operator’s review limit. The blank line contributes nothing. The large amount is still valid data, but the audit should stop as soon as it finds that amount. A malformed line is different again: the import cannot produce a trustworthy batch.
No single sentinel can preserve all three meanings. The importer instead carries each outcome in the type that owns it:
enum ImportOutcome {
Ready(Vec<EntryState>),
NeedsReview { entries: Vec<EntryState>, index: usize },
}
fn import(lines: &[&str], limit: u64) -> Result<ImportOutcome, DomainError> {
let entries: Vec<EntryState> = lines
.iter()
.filter_map(|line| parse_line(line).transpose())
.collect::<Result<_, _>>()?;
match first_over_limit(&entries, limit) {
ControlFlow::Continue(_) => Ok(ImportOutcome::Ready(entries)),
ControlFlow::Break(index) => {
Ok(ImportOutcome::NeedsReview { entries, index })
}
}
}
The function does not merely use several Rust conveniences. It composes several contracts. parse_line distinguishes an absent entry from an invalid one. transpose turns that nested outcome into the shape the iterator needs. collect either stages a complete vector or returns the first parsing error. first_over_limit can end its search without pretending that a successful discovery is an error. Only the importer gives that discovery the domain name NeedsReview.
The governing rule is to preserve an outcome until a caller with enough policy knowledge can translate it honestly.
Absence and failure are independent dimensions
Option<T> says that presence and absence are both expected outcomes and that absence needs no explanation at this boundary. Result<T, E> says that the caller needs either a value or recoverable failure information. Neither type is the universally stronger choice.
The parser needs both dimensions:
pub fn parse_line(line: &str) -> Result<Option<EntryState>, DomainError> {
if line.trim().is_empty() {
return Ok(None);
}
let (account, cents) = line.split_once(',').ok_or(DomainError::InvalidLine)?;
let cents = cents.parse().map_err(|_| DomainError::InvalidLine)?;
Ok(Some(EntryState::Pending {
account: AccountId::parse(account)?,
amount: Amount::new(cents)?,
}))
}
Ok(None) means the parser understood the line and found no entry. Err(DomainError::InvalidLine) means it could not establish the required structure. Empty account names and zero amounts retain their own domain errors. Replacing all four cases with Option<EntryState> would make corrupt input indistinguishable from a harmless separator. Replacing the blank line with an error would force file-layout policy into a parser whose caller may legitimately permit whitespace.
Combinators are useful at the points where one value changes locally. ok_or turns a missing comma into the error this parser owns. map_err translates the integer parser’s error because the domain does not expose integer-parsing details. map transforms success, and_then sequences another computation in the same control type, and filter may turn a present optional value into absence. Their names do not make a translation correct: the destination contract must still preserve every distinction the caller can act on.
? follows the enclosing return contract
In an ordinary Result-returning function, expression? extracts Ok(value) or returns early with Err, applying the required From conversion to the error. In an Option-returning function, it extracts Some(value) or returns None. Stable application code is clearest when reviewers reason from that concrete enclosing type rather than from the operator’s underlying traits.
The two ? uses around AccountId::parse and Amount::new need no conversion because those functions already return DomainError. In a service whose storage layer returns StorageError, ? may invoke From<StorageError> for DomainError. That implementation is part of the service’s error contract. A blanket conversion to DomainError::Invalid may compile while destroying whether a lookup, permission check, or input validation failed.
Read ? as typed early propagation, not as “throw.” It is a good fit when the enclosing function agrees with the lower operation’s policy. Use an explicit match when the caller recovers, emits a side effect, or changes the meaning of the outcome.
Figure 15-1 separates the paths used by the importer. It is a propagation map, not a claim that None, Err, Break, function return, and divergence are interchangeable.
Transpose and collect align neighboring contracts
The parser returns Result<Option<EntryState>, DomainError>, but filter_map expects its closure to return Option<Item>. transpose performs the exact structural exchange:
Ok(Some(entry))becomesSome(Ok(entry)), so the entry reachescollect;Ok(None)becomesNone, sofilter_mapomits the blank line;Err(error)becomesSome(Err(error)), so the error remains in the iterator.
The resulting iterator yields Result<EntryState, DomainError>. Collecting it into Result<Vec<EntryState>, DomainError> accumulates entries until the first error, then returns that error. It does not report every malformed line. It also makes no transaction promise about external effects. The example is all-or-nothing only because parsing is side-effect free and the vector remains staged until collection succeeds. If the closure writes records as it parses, the same elegant expression can leave a partially applied import.
An iterator of Option<T> can likewise collect into Option<Vec<T>>, where any None makes the whole result absent. Use that form only when “one missing item invalidates the collection” is the domain rule. Compact composition is valuable when the nested contracts truly line up; otherwise write the loop whose state and policy the reader needs to see.
A traversal break can be a successful discovery
ControlFlow<B, C> distinguishes Break(B) from Continue(C). The lab’s first_over_limit returns the index in Break and the number of entries inspected in Continue. Neither branch says that parsing or auditing failed. The traversal simply has enough information to stop or has reached the end.
The importer then translates those mechanism-level outcomes into NeedsReview and Ready. Returning an error for the over-limit amount would mislabel valid data as failed work. Returning only Option<usize> would lose the useful completed-scan value. At a public domain boundary, a named enum may communicate better than raw Break and Continue; the standard type is especially useful inside traversal and try_fold-style APIs.
This is the same boundary discipline used elsewhere. A repository may return Result<Option<Row>, StorageError>. A domain service turns None into DomainError::UnknownAccount only when its operation requires the account to exist. An HTTP adapter later maps that error to a status and an operator-safe body. Meaning changes where ownership of the decision changes.
Attach context at those boundaries, but do not log every propagation layer. Emit once where the failure is handled, and do not place secrets or raw untrusted records into convenient error strings.
Divergence makes the remaining path possible
The never type ! describes a computation that does not return normally. A return expression, a break expression in its loop context, panic, process exit, or an infinite loop can leave another branch to determine an expression’s value because the diverging branch contributes no competing value.
let Some(entry) = maybe_entry else {
return Ok(());
};
record(entry)
The else branch must diverge, so after the statement entry is known to exist. This is a control-flow fact, not a recoverable error value. A panic also diverges, but that does not make panic an appropriate substitute for validation. Reserve it for violated internal invariants or deliberately documented unrecoverable conditions. The never type still has evolving corners in generic type positions; this chapter relies only on stable expression behavior.
Let readability expose ownership and policy
The importer uses a mixed style for a reason. The iterator chain performs one-directional, side-effect-free transformation. ? propagates the parsing policy unchanged. The final match translates a traversal result into application policy and moves the completed vector into exactly one outcome.
A longer chain would not be more compositional if it buried that last decision in closures. Dense combinators can hide when values move, how long borrows last, whether error conversion is lossy, and which side effects have already occurred. Prefer a named helper when a closure contains a concept worth testing. Prefer let ... else when one required shape should leave the happy path flat. Prefer match when branches recover differently or own different effects.
The standard is not the fewest lines. It is whether a reviewer can point to every exit, say who named it, and see what work has happened before it is taken.
Failure modes
- Collapsing malformed input and an intentionally blank line into the same
None. - Turning every
Noneinto failure before the domain boundary decides whether presence is required. - Using
?through a lossyFromconversion that collapses actionable failures. - Collecting results while assuming every error will be reported or prior side effects will be rolled back.
- Encoding a successful early discovery as
Errbecause the traversal stopped. - Using panic for normal validation or environmental failure.
- Building a combinator chain whose ownership and effects are harder to trace than an explicit branch.
Senior review checklist
- Is absence a valid outcome, or does the caller need a reason?
- Does each error variant support a caller decision?
- What exactly does
?return, and whichFromconversion runs? - Is iterator collection intentionally first-error/all-or-nothing?
- Are partial side effects possible before propagation?
- Is an early traversal stop success, failure, or a separate domain outcome?
- Does any diverging branch represent a documented invariant failure?
- Would an explicit match make recovery, logging, or ownership clearer?
Refactoring exercise: compose an import without hiding policy
Begin with an imperative ledger importer that uses sentinel strings, unwrap, and a boolean stop flag. Before rewriting it, assign a meaning to every exit: blank line, malformed line, duplicate account, over-limit entry, clean completion, and write failure.
Now implement the parse-and-audit phase with Result<Option<EntryState>, DomainError>, transpose, collection over Result, and ControlFlow. Keep it free of writes. Add duplicate detection with enough account context for the caller to act, then decide whether a duplicate invalidates the batch or produces a review outcome. Use let ... else at one boundary where the remaining path requires presence, and an explicit match where policy changes. Finally introduce a transactional writer and identify the last point at which ? can return without leaving externally visible partial work. Do not use every construct merely to satisfy the exercise; explain what each surviving construct reveals.
Durable takeaways
Optionmodels expected absence;Resultpreserves recoverable failure information.?propagates according to the enclosing return type and may convert an error throughFrom.transposeandcollectare powerful when adjacent control contracts align exactly.ControlFlowcan stop traversal without calling a successful discovery failure.- Divergence leaves no normal continuation, while explicit branches keep policy, effects, and ownership reviewable.
Legal values, exhaustive decisions, and honest exits describe one step. The next chapter adds time: which transitions must remain runtime data, and which short local sequences are worth proving through consuming types?
Sources and version notes
std::option::Option,std::result::Result, andstd::ops::ControlFlow- Rust Reference: question-mark expressions
- Rust Reference: never type
- Executable behavior was verified with Rust 1.97.0 and Rust 1.85.0. The chapter makes no claim about unstable generic never-type capabilities.
Continue reading
Full table of contents