Lesson 08 / 15
Failure as a Return Value
Writing an operation's success and failure paths as separate bodies in the source, and measuring how the run counts of these two arms diverge while the total call count stays fixed as the same function is called repeatedly.
Published Updated
In this lesson
- Shows by executing that the success and failure arms are separate bodies
- Verifies by counting how the arm counts diverge while the total call count stays fixed
- Distinguishes with an example that an arm written in the source may never run in a given run
Contents
None has no reason — it says that a value was not found, but not why. When a file-open
call fails, what the caller needs to know is not just “no result”; it also needs to know
whether the file was not found, permission was denied, or something else happened. This
lesson measures how many times, and which arm, a result that also carries a
reason — Result<T, E> — runs.
The axis measured in this topic’s first lesson (whether it runs eagerly or lazily) does
not hold here; there is no relationship like “one is the cheaper form of the other”
between Ok and Err. Instead, a new question comes into play: of two different bodies
sitting side by side in the source, which one runs depends on the input — and how does
that vary, and accumulate, over the course of a run?
The Interfaces, Errors and Generics course established that an error is placed in the
third of its three binding kinds — inside the value: the error is carried neither in a
signature nor at a call site, but directly in the return value itself. This lesson
inherits that decision — Result follows exactly this arrangement — but asks a different
question: when the same function is called repeatedly, how many times does each of the
success and failure arms run?
The Expressing Absence with a Type lesson measured that the eager and lazy forms do the
same job, differing only in how many times they run. The difference measured in this
lesson sits somewhere else: the Ok and Err arms do not do the same job — they run two
entirely different bodies. The question is no longer “how many times does the same job
run” but “which job, how often” — what share of the run belongs to each of the two
different bodies written in the source.
A Result, Two Paths
Result<T, E> is another standard-library instance of the enum mechanism set up in the
previous course: Ok(T) carries the successful result, Err(E) carries the reason for
failure. Its difference from Option is that, unlike None, Err carries a value of
its own — failure here is not an empty state, but data with its own type. Option
answers the question “is there a value”; Result can also answer “why did the operation
fail.”
EH4. These two types use the same enum mechanism, but they carry different
information: Option‘s None carries no data, Result‘s Err does. This also means the
two types cannot substitute for each other: Option is enough to say whether a lookup’s
result was found; Result is needed when it is necessary to say why an operation failed.
A codebase can see both together — a Result<Option<T>, E>, for instance, carries both
the possibility that the operation failed and the possibility that, even on success, no
value was found; this combination is not measured in this lesson, but it clarifies why
the two types are kept separate. If a function’s return type is Result<i32, NumberError>,
the caller does not just ask “did it fail” but can also read which error occurred. The
NumberError used in this lesson is an empty struct that carries no fields — the error
itself does not matter in this lesson, what matters is which arm runs when. The Custom
Error Types lesson will focus on the error itself, on the information it carries.
In the match above, the Err arm does not bind the error itself to a name; it writes
Err(_) — the underscore is a form of the catch-all pattern established in the Pattern
Matching lesson, and it is the source’s way of saying “I will not read this value.”
Because NumberError carries no fields, there is nothing to read here anyway; once the
error starts carrying information in the next two lessons, Err(_) will give way to a
pattern that actually binds the error.
Option’s _or / _or_else naming pattern continues on Result too: unwrap_or,
unwrap_or_else, map_or, and_then — all of them have a Result counterpart, and all
of them carry the same eager/lazy distinction. But this lesson does not measure those
methods; it measures the side that is unique to Result, the case where the two arms
do different jobs. In a method like unwrap_or, both “sides” would do the same job
of the same type (producing a value); here the Ok arm continues a computation, and the
Err arm stops the computation and produces a reason — two different jobs.
A function returning Result does not mean that function must resolve its own error
internally. resolve produces its own Err and returns it; the caller can decide to use
that value, ignore it, or carry it unchanged into its own return value. This third
option — carrying an error upward without processing it — can cross several function
boundaries in a row, and does not require writing a new arm in the source at each
crossing. This lesson does not measure that carrying; it only counts, at a single
boundary, over a single match, how many times the two arms run. The carrying itself is
the subject of the Shorthand for Error Propagation lesson.
Which Arm Runs
When a function returns Result, the caller usually separates the two arms with a
match. Both arms are ordinary Rust code — neither runs through a separate mechanism
from the other, both are part of the same match expression that looks at the same
function’s return value. This means both arms sit side by side in the compiled binary,
and which one runs depends only on the returned value (Ok or Err). The run below
calls the same function with three different inputs and counts which arm runs on each
call.
// error_check.rs — success and failure arms as separate bodies
pub type Ledger = std::rc::Rc<std::cell::RefCell<std::collections::BTreeMap<String, u32>>>;
pub fn record(ledger: &Ledger, name: &str) {
*ledger.borrow_mut().entry(name.to_string()).or_insert(0) += 1;
}
pub fn times(ledger: &Ledger, name: &str) -> u32 {
*ledger.borrow().get(name).unwrap_or(&0)
}
#[derive(Debug)]
pub struct NumberError;
pub fn resolve(text: &str) -> Result<i32, NumberError> {
match text.parse::<i32>() {
Ok(number) => Ok(number * 2),
Err(_) => Err(NumberError),
}
}
// two_arms.rs — the same function is called three times, counting which arm runs
mod error_check;
use error_check::{Ledger, record, times, resolve};
use std::cell::RefCell;
use std::rc::Rc;
use std::collections::BTreeMap;
fn main() {
let ledger: Ledger = Rc::new(RefCell::new(BTreeMap::new()));
for text in ["8", "twenty-one", "15"] {
match resolve(text) {
Ok(result) => {
record(&ledger, "success-arm");
println!("{:<10} success arm, result {}", text, result);
}
Err(_) => {
record(&ledger, "failure-arm");
println!("{:<10} failure arm", text);
}
}
}
println!();
let total = times(&ledger, "success-arm") + times(&ledger, "failure-arm");
println!("total calls: {}", total);
println!("success-arm: {} times", times(&ledger, "success-arm"));
println!("failure-arm: {} times", times(&ledger, "failure-arm"));
}
8 success arm, result 16 twenty-one failure arm 15 success arm, result 30 total calls: 3 success-arm: 2 times failure-arm: 1 times
resolve is called three times, and the total call count is fixed: three. But these
three calls’ arms are not distributed the same way: two calls fall to success-arm, one
call to failure-arm. EH5. This shows that the two bodies written in the
source — the Ok arm’s body and the Err arm’s body — run independently of each other:
while the total stays fixed, which arm is chosen how many times depends only on the
input. The text "twenty-one" cannot be parsed as an integer, so the Err arm runs;
"8" and "15" can be parsed, so the Ok arm runs. Both arms are written at equal
length in the source, but how much each one runs in a given run is a number that depends
on the input.
This distinction sits in a different place from what was seen with Option. The
difference measured in the previous lesson was between an eager and a lazy body —
both could do the same job, and they differed only in run timing. Here, the two arms do
entirely different jobs: one doubles the result, the other produces an error value. The
count itself does not change this difference — the times function does not know what
job each arm does, it only counts how many times it was called. The measurement stays
the same here too: how many times a body written in the source runs in a given run.
These three inputs stand in for a validation pipeline: three separate fields entered by a
user, parsed in turn. In a real validation pipeline, inputs do not arrive one at a time
but as a list, and the same resolve call repeats for every field. The for loop above
shows this pattern at a small scale: three pieces of text pass through the same function,
and each one selects its own arm. The loop itself is not a single “run” — it is the sum
of three separate calls. What matters for this lesson is that each step of the loop is an
independent call, and each call selects its own arm without being affected by earlier
calls: the second call returning Err has no effect whatsoever on which arm the third
call selects. The counter ledger naturally preserves this independence — each record
call only increments its own key and touches no other key.
The match expression itself carries a principle established earlier, by the Control
Flow lesson: match resolve(text) { ... } is an expression, and it produces a
value — here, the value produced is (), because both arms only produce a side effect (a
counter record and a print) without visibly returning a value. This shows again the
difference between match itself being an expression and what value its arms produce:
the Control Flow lesson established that match is an expression and that all its arms
must yield the same type; here every arm produces (), but the body inside each arm is
entirely different from the other’s. An expression itself may produce a single value,
even while the code that runs to reach that value diverges arm by arm.
A Limit on the Measurement
Both arms are written in the source, but only one of them runs in a given call. The run below makes this concrete with a single call.
// single_call.rs — both arms are written in the source, but only one runs in a single call
mod error_check;
use error_check::{Ledger, record, times, resolve};
use std::cell::RefCell;
use std::rc::Rc;
use std::collections::BTreeMap;
fn main() {
let ledger: Ledger = Rc::new(RefCell::new(BTreeMap::new()));
match resolve("100") {
Ok(result) => {
record(&ledger, "success-arm");
println!("result: {}", result);
}
Err(_) => {
record(&ledger, "failure-arm");
println!("an error occurred");
}
}
println!("success-arm: {} times", times(&ledger, "success-arm"));
println!("failure-arm: {} times", times(&ledger, "failure-arm"));
}
result: 200 success-arm: 1 times failure-arm: 0 times
EH6. Because the resolve("100") call gives an Ok result, only success-arm runs;
failure-arm stays at zero. But this zero does not mean the Err arm does not
exist — that arm stands in the compiled binary, the compiler produced it, match was
required to cover it under exhaustiveness. It was never selected in this
particular run. An arm being written in the source and an arm having run in a given run
are two separate facts: one is a property of the source code, the other is the result of
a particular run made with a particular input. This distinction between “covered
code” — code the compiler accepts, satisfying the exhaustiveness principle — and
“executed code” — code actually run by the processor in a particular execution — also
summarizes the difference between reading a program’s source and measuring one of its
runs.
The importance of this distinction becomes clearer when you consider what the
exhaustiveness principle actually provides. Without the exhaustiveness principle — that
is, if match allowed writing only the Ok arm and ignoring the Err case — a body
called failure-arm might never exist in the source at all, and when an input like
"twenty-one" arrived, the program would fall into an undefined state: no error value
would be produced, and the flow would not know where to go. The exhaustiveness principle
prevents this — the Err arm must exist in the source, whether it never runs in a
given run or takes over the entire run. Having run zero times does not mean it was not
needed there; on the contrary, the reason zero can even be measured is that the arm is
already there, standing ready to run.
This difference may not look significant in a small example, but it gains importance as
the number of call sites for a function grows. If a function is called thousands of times
and most of the inputs are valid, the Err arm still takes up space in the source, gets
compiled, gets checked — but may almost never run in a given run. The reverse is also
possible: if a function is called mostly with invalid inputs, the Ok arm may stand in
the source but run rarely in a given run. The source itself does not say which arm will
run more often; only an actual run can say that.
In the second run above, two of the three inputs succeeded and one failed —
success-arm ran twice, failure-arm once. If the same function were called with a
thousand inputs and only a few of them were invalid, the ratio would shift accordingly:
success-arm could sit near a thousand while failure-arm stayed in the single digits.
The length of the two arms written in the source has nothing to do with this ratio — the
Err arm could be a one-line body and still take over nearly the entire run, if the
inputs call for it. This lesson does not try to predict that ratio; it only shows that
the ratio must be read from a run, not from the source.
The two runs above together partly answer the question asked at this topic’s opening: in
Option, whether the fallback body ran depended on whether it was written eagerly or
lazily; in Result, whether an arm runs depends on which input it corresponds to. Both
fit the same measurement frame — how many times a body written in the source runs in a
given run — but each falls into the “every time,” “only when needed,” or “never” class
for its own reason.
Summary
Result<T, E>, unlikeOption, also carries the reason for failure:Ok(T)andErr(E)hold separate information;Option’s_or/_or_elsenaming pattern applies here too.- The success and failure arms’ bodies are written separately in the source and run independently of each other; when the same function is called repeatedly, the total call count stays fixed, but the arm counts diverge with the input.
- An arm being written in the source does not mean it runs on every run — in a single call, only one of the two arms runs, and the other runs never in that run.
- “Covered code” (code the compiler accepts, written to satisfy exhaustiveness) and “executed code” (code that actually runs in a given run) are two separate facts.
- A catch-all pattern like
Err(_)is enough to count which arm ran without reading the error’s content; the information an error carries is the subject of the next lessons. - How often each arm will run cannot be read from the source; it can only be read from a run made with real inputs. In this run, both arms ran only when needed: each call ran the arm its own input required, and the other one ran never on that call.
Next Step
In both runs above, the success arm repeated the same three lines: parse the value,
report if there is an error, otherwise continue. This pattern quickly turns into one that
repeats in a function that does several parses in a row — especially when a function has
to call another parse internally and check its result too, the same match skeleton
gets written several times in a row. The Shorthand for Error Propagation lesson measures
a form that shortens this pattern to a single mark — and shows that the shortcut shortens
the source but does not change the run, meaning success-arm and failure-arm give the
same counts seen in this lesson. The shorthand reduces not the count being measured, but
the number of lines that must be written to reach that count — this lesson built the
match skeleton by hand, the next lesson measures how much the same skeleton can be
shortened in the source without changing the run.
To keep your progress and take notes, Log in
My notes
Log in to take notes.