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.