Skip to content
academia.sh

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>, unlike Option, also carries the reason for failure: Ok(T) and Err(E) hold separate information; Option’s _or / _or_else naming 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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close