Skip to content
academia.sh

Lesson 10 / 15

Custom Error Types

Measuring that the question mark converts one error type into another without the caller writing any conversion call, and that this implicit conversion — unlike a hand-written one — cannot carry context information from the caller.

Published Updated

In this lesson

  • Shows by executing that the question mark runs an error conversion without the caller writing anything
  • Verifies by counting in how many links of an error chain the conversion actually ran
  • Distinguishes by comparison with a hand-written conversion that the implicit conversion loses the caller's context information
Contents

The Shorthand for Error Propagation lesson showed that the question mark converts different error types into each other, but it did not go into how that conversion happens — it only used it. This lesson opens up that conversion: when an error moves from one type to another, who calls that transition, and is that call visible in the caller’s source?

The impl From<std::num::ParseIntError> for NumberError block set up in the previous lesson is the piece that unlocks this question. That lesson used this block only as a precondition needed for the question mark to work; this lesson examines that same block itself — what is built inside it, what is discarded, and who triggers that construction, how many times.

The Interfaces, Errors and Generics course measured error wrapping and the error chain — whether an error carries another error inside it, how an error’s identity is preserved through a chain. This lesson measures not wrapping but conversion: an error type moves forward not by keeping its own form, but by being converted entirely into another type. Rust keeps these two mechanisms separate — wrapping was that course’s subject, conversion is this lesson’s.

Wrapping puts an error inside an envelope; inside that envelope, the original error stays intact and can be taken back out when needed. Conversion, by contrast, destroys the original error: the source type is given up, a new type takes its place, and that new type can no longer turn back into the old one — unless it stored the old one inside itself as a field. This lesson measures this second mechanism with an example where NumberError does not store ParseIntError inside itself, it only reduces it to a signal. The From implementation shows where this reduction happens: the source error’s data is either kept or discarded while the target type is being built — that is the decision of whoever writes the implementation.

This topic’s first three lessons measured how many times Option’s and Result’s own success and failure arms ran; the error values inside those arms did not matter in themselves, NumberError was an empty struct carrying no information. This lesson fills that emptiness: NumberError now carries a field, and whether that field is filled correctly or not depends on where and how the conversion is done.

The word “custom” in the lesson title points to an error type designed to be specific to its source: NumberError is not part of the standard library, it is a struct built to carry this program’s own form of failure. In a real program, such a type could also be an enum instead of a single field — each variant could carry a different kind of failure (a field missing entirely, a number out of range, a format mismatch). This lesson does not build that variety; with a single struct and a single field, it focuses on the question of who calls the conversion. Each variant of a diversified error type could use the same From mechanism too; the axis being measured does not change.

Who Calls It: Implicit Conversion

If an error type can be produced from another error type through a From implementation, the question mark calls that production automatically. There is no line in the caller’s source showing that call — ? is the only character standing there.

The run below stands in for a small chain that parses a three-field record: the age, score, and attempt fields are each checked in turn by the link function. The chain itself is linear — one field does not wait for another, all are called independently — but the word “chain” here describes the sum of three separate links: how many links the conversion actually ran in is read from the total of the three.

// conversion.rs — the NumberError/From slice of times.rs: how many links the conversion ran in
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;

type Ledger = Rc<RefCell<BTreeMap<String, u32>>>;

fn record(ledger: &Ledger, name: &str) {
    *ledger.borrow_mut().entry(name.to_string()).or_insert(0) += 1;
}
fn times(ledger: &Ledger, name: &str) -> u32 {
    *ledger.borrow().get(name).unwrap_or(&0)
}

struct NumberError {
    field: String,
}

impl From<std::num::ParseIntError> for NumberError {
    fn from(_: std::num::ParseIntError) -> NumberError {
        NumberError { field: String::from("unknown") }
    }
}

fn link(_field: &str, text: &str) -> Result<i32, NumberError> {
    let number: i32 = text.parse()?;
    Ok(number)
}

fn main() {
    let ledger: Ledger = Rc::new(RefCell::new(BTreeMap::new()));
    let entries = [("age", "thirty"), ("score", "78"), ("attempt", "three")];

    for (field, value) in entries {
        match link(field, value) {
            Ok(number) => println!("{:<8} success: {}", field, number),
            Err(error) => {
                record(&ledger, "error-conversion");
                println!("{:<8} failure, recorded field: {}", field, error.field);
            }
        }
    }
    println!();
    println!("links in the chain: {}", entries.len());
    println!("links where conversion ran: {} times", times(&ledger, "error-conversion"));
}
age      failure, recorded field: unknown
score    success: 78
attempt  failure, recorded field: unknown

links in the chain: 3
links where conversion ran: 2 times

EH10. No line in link’s body other than text.parse()? mentions NumberError — the ParseIntError that parse produces automatically converts into link’s return type, NumberError, because an impl From<ParseIntError> is written for NumberError. Two of the three links in entries ("age" and "attempt") cannot be parsed, and error-conversion increases twice; because "score" can be parsed, conversion never runs in that link. While the number of links in the chain stays fixed (three), how many links the conversion actually ran in depends on the input — here, two out of three.

EH11. Both printed field values are "unknown" — even though the fields that actually failed are "age" and "attempt", the error value itself does not know this. The From implementation’s signature is fixed: it only takes a ParseIntError, it does not know which field argument link was called with. The conversion really does run — the error-conversion counter proves it — but the caller (the ? inside link) cannot pass any context to this conversion, because what calls the conversion is not link itself but the code the compiler generates for ?.

The only place in link’s body that mentions NumberError is the return type (Result<i32, NumberError>); the body itself only writes text.parse()?. This is the same benefit of the shorthand measured in the previous lesson — the source stays short — but its cost shows up here too: as the source shortens, the information that source can call on narrows as well. Whoever calls link knows the field (the first element in entries), but link’s own body carries no way to pass that information on to NumberError, because the passing happens behind ?, outside link’s control.

Both increases in error-conversion fall into the “every time” class: for every field with invalid input, the conversion runs unavoidably — not conditionally, not probabilistically, if the input is invalid the conversion runs with certainty. This is different from the “only when needed” class seen with things like lazy-default-needed or after-question-success in previous lessons: there, the count could be zero or one depending on the input; here, for every failed link, the conversion runs exactly once, unconditionally.

The three elements in the entries array are processed in the same order on every repetition of the run; changing that order does not change the error-conversion count, because each link is evaluated independently — an earlier link succeeding or failing has no effect on the next link’s result. This continues the same pattern introduced as a validation pipeline in the previous lesson: here it additionally counts whether each link ran a conversion too. The resulting count of “2 times” says, together, exactly how many of the three links failed and that the conversion was actually triggered in each of those failures.

A Limit on the Measurement

Every error conversion can lose information. If the converted-to type does not carry the source, the lost information cannot be brought back unless it is carried by hand. This is not a specific shortcoming of NumberError, it is a natural consequence of the From signature: fn from(value: Source) -> Target only takes the value of type Source, it sees no variable from around where it was called. This limit is the same for every From implementation — it only becomes visible for types like NumberError that need information outside the source error (such as the field name). The run below repeats the same scenario, writing the conversion by hand.

// manual_conversion.rs — the caller writes the conversion explicitly, losing no context
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;

type Ledger = Rc<RefCell<BTreeMap<String, u32>>>;

fn record(ledger: &Ledger, name: &str) {
    *ledger.borrow_mut().entry(name.to_string()).or_insert(0) += 1;
}
fn times(ledger: &Ledger, name: &str) -> u32 {
    *ledger.borrow().get(name).unwrap_or(&0)
}

struct NumberError {
    field: String,
}

fn link_by_hand(field: &str, text: &str) -> Result<i32, NumberError> {
    text
        .parse::<i32>()
        .map_err(|_| NumberError { field: field.to_string() })
}

fn main() {
    let ledger: Ledger = Rc::new(RefCell::new(BTreeMap::new()));
    let entries = [("age", "thirty"), ("score", "78"), ("attempt", "three")];

    for (field, value) in entries {
        match link_by_hand(field, value) {
            Ok(number) => println!("{:<8} success: {}", field, number),
            Err(error) => {
                record(&ledger, "error-conversion");
                println!("{:<8} failure, recorded field: {}", field, error.field);
            }
        }
    }
    println!();
    println!("links in the chain: {}", entries.len());
    println!("links where conversion ran: {} times", times(&ledger, "error-conversion"));
}
age      failure, recorded field: age
score    success: 78
attempt  failure, recorded field: attempt

links in the chain: 3
links where conversion ran: 2 times

EH12. The counts are exactly the same: conversion runs in two of the three links. But the field values are now correct — when "age" fails, the error prints "age"; when "attempt" fails, the error prints "attempt". The difference is that the closure given to map_err can capture the field parameter: the field variable in link_by_hand’s own scope is reachable from inside the closure. From::from does not allow such a capture — its signature only takes the source error, it takes nothing from the calling context. This is concrete proof of why the shorthand (?) sometimes falls short: the shorthand is fast and short, but it can only carry as much information as the target type can produce from the source error; if it needs to carry context outside the source error (here, the field name), a hand-written conversion is needed.

This limit points to a general rule: a conversion is irreversible if the target type does not hold the source or its context. NumberError having a field makes this loss visible — if it had been an empty struct (as in the previous two lessons), there would have been nothing to lose, because there was no information to carry in the first place. When an error type is designed to carry information, this design decision comes along with it: an implicit conversion, or a hand-written one that preserves context?

The difference between link and link_by_hand is a variation on the “same count, different source length” example seen in the previous two lessons. There, the shorthand and the hand-written form gave the same count and the difference was only in line count; here too the count stays the same, but the difference is no longer in the line count, it is in the information the error carries. The brevity the shorthand wins this time comes with a cost — and that cost becomes visible not through the run counter, but through the printed field value.

The count alone is not a sufficient criterion when choosing between these two designs — both runs gave the same “links where conversion ran: 2 times” line. The decision lies between how short the source will stay and how much information the error will carry. link, with its one-line body, represents the shortest form, but its error always says "unknown". link_by_hand carries a three-line map_err call, but its error names the correct field. A codebase can contain both forms: if which field an error came from does not matter (if just “something failed” is enough), the shorthand can be preferred; if the error’s source will be shown to a user or a log record, a hand-written conversion is needed.

The same implicit/manual distinction also raises a question about the conversion’s cost — but this lesson does not measure that cost. From::from’s body here amounts to building a single struct; map_err’s closure does the same job. How many times each of them ran was measured above (two), and each body is the same size. The only difference is in what gets built: one builds a fixed string, the other a string coming from the calling context.

The Trait Definition and Implementation lesson measured that a trait’s default body might never run on one type and might run on another — two different implementations of the same contract could give two different run counts. From<ParseIntError> for NumberError is a trait implementation too, and this lesson carries the same principle: the implementation is written once in the source, but in a run it can be called zero or more times depending on the input. The difference is that this implementation’s call never appears explicitly in the source — it stays behind ?.

Summary

  • If an error type can be produced from another error type with From, the question mark calls that production automatically; no line in the caller’s source shows that call.
  • Which links in a chain the conversion actually ran in can be counted; here the conversion runs every time in two of the three links — unavoidable for every failed link, not conditional.
  • From::from’s signature only takes the source error; it can take no information from the calling context (such as which field failed).
  • A hand-written conversion (a closure with map_err) can preserve this information because it can capture variables from the calling scope — it gives the same count while carrying more information.
  • Every error conversion can lose information; if the target type does not carry the source or its context, this loss cannot be reversed.
  • The choice is between the source’s brevity and the richness of the information the error carries; whichever is chosen, the count of how many times the conversion runs stays the same.

Next Step

This topic’s first four lessons measured the situations where an operation might fail — Option‘s absence, Result‘s arms, the shorthand’s silent skipping, the conversion’s loss of information. One thing is common to all of them: the caller took the failure as a value and decided for itself what to do — it continued, used a fallback, returned early, carried the information, or lost it. But the decision was always the caller’s.

The Panic and Unrecoverable Errors lesson measures the exact opposite: a design where a failure, without producing any value, without leaving the caller any choice, stops the remaining body outright. A function returning Result presents its failure as data and even lets the caller ignore it; the design measured in this topic’s last lesson, on the contrary, models a stop that cannot be ignored — and it compares how the remaining-body counts the two designs give for the same failure diverge.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close