Missing cells & fallible parses — Option and Result
Clinker’s streaming reader answers two questions on every record: is this field even
here? and did reading it fail? It encodes both in the type its hot loop returns,
Result<Option<Record>, FormatError>, so a caller can’t read a row without first
confronting “maybe absent” and “maybe failed.” You already met absence in passing while
moving and borrowing a record, where a field lookup returned
Option<&Value>: “maybe there, maybe not.” This lesson is the deep pass: how the engine
stacks absence inside failure into one composed type, and reads it as three distinct
outcomes. The Rust tools underneath are Option and Result; we use just enough of each
to read the real signature, and
The Rust Book, ch. 9
is the canonical treatment of Result and the ? operator if you want them from first
principles.
What you’ll be able to do
Section titled “What you’ll be able to do”- Read
Option<T>andResult<T, E>in real engine signatures and name what each variant encodes. - Decompose the reader type
Result<Option<Record>, FormatError>into its three outcomes and explain what each one means for a streaming job. - Write a fallible coercion function that returns the composed type, distinguishing a clean absence from a real error.
- Distinguish a recoverable (dead-letter) error from a fatal one, and say why the type system makes the split visible.
New terms in this lesson (each is also expanded inline at the point you first need it):
Option<T>: absence encoded in the type.Result<T, E>: failure encoded in the type.Result<Option<T>, E>: the two composed, read as three outcomes.- the
?operator: one-character early-return-on-error, pre-taught where it appears below.
Predict first
Section titled “Predict first”Start concrete: one Option, one Result
Section titled “Start concrete: one Option, one Result”Before composing anything, look at each half on a real value.
Option<T>: maybe there, maybe not
Section titled “Option<T>: maybe there, maybe not”You already met one in the last lesson: resolve(&self, name: &str) -> Option<&Value>.
An Option<T>
is honest about a fact the type system would otherwise hide: a field you ask for might
not exist. It’s Some(value) when it’s there and None when it isn’t. There’s no
null to forget to check; to get at the value you must handle the None arm.
Here it is on a concrete field lookup you can run:
> output appears here — press Run
Notice the return type is Option<&Value>, not Option<Value>. That & is the
borrow
you studied last lesson, lending a window onto the value already in the row instead of
copying it out. The Option wraps that borrow; it doesn’t change why it’s a borrow.
Result<T, E>: maybe it failed
Section titled “Result<T, E>: maybe it failed”Reading a value is one thing; transforming one can fail. Coercing the text "active" to
an integer is nonsense. A
Result<T, E>
makes that failure a value you handle rather than an exception that unwinds the stack: it’s
Ok(value) on success and Err(error) on failure.
> output appears here — press Run
Two halves, each on a concrete value: Option says present or absent; Result says
succeeded or failed. Keep them separate in your head. That separation is exactly what
makes the composed type readable.
Compose them: the streaming-record read pattern
Section titled “Compose them: the streaming-record read pattern”Now put the halves together. A reader streams records one at a time, and on each call two independent things can be true:
- Is there a next record, or has the finite source run out? → that’s an
Option:Some(record)orNone. - Did the attempt to read it succeed, or did it hit a malformed line? → that’s a
Result:Ok(...)orErr(e).
Stack the Option inside the Result and you get the type the engine actually uses:
Result<Option<Record>, FormatError>// ^^^^^^^^^^^^^^^ inner: is there a next record? (Option)// ^^^^^^ outer: did the read succeed? (Result)Read it from the outside in, and it resolves to the three outcomes you predicted at the top:
// Ok(Some(record)) → a value (here is the next row)// Ok(None) → clean end-of-input (the finite source is exhausted; stop)// Err(e) → something went wrong (a malformed line, a broken encoding)Ok(None) and Err(e) are the pair people conflate. They are opposites: end-of-input is
a successful outcome that happens to carry no record; an error is a failure. The
composed type forces your loop to treat them differently, and that’s the point.
Worked → completion → faded
Section titled “Worked → completion → faded”Now you read and write the composed type, scaffolded down to your own.
Worked: read all three outcomes
Section titled “Worked: read all three outcomes”Here is a self-contained reader that returns the engine’s exact type and a loop that handles every outcome. Run it and watch the three branches fire.
> output appears here — press Run
Completion: write the third arm
Section titled “Completion: write the third arm”Below, two match arms are written for you. The Err(_) arm is the one to complete: a
read error means this record can’t be produced. Under a continue strategy the engine
routes it to the dead-letter queue and keeps going; otherwise it aborts. Fill in a string
that captures that.
> output appears here — press Run
💡 Hint 1
Err is neither a row nor a clean stop. It’s the only outcome where something failed, so the loop must decide between recover (DLQ) and abort.Show solution
Err(_) => "an error — DLQ the row or abort the run",The exhaustive match is what guarantees you considered all three. Drop any arm and the
compiler refuses to build; that’s the exhaustiveness you met back in
reading a value with match, now doing real work on a composed type.
Faded: write the whole function
Section titled “Faded: write the whole function”Your turn with much less scaffolding. Write coerce_to_int returning
Result<Option<i64>, CoercionError>. The rule: a Null cell is legitimately absent
(map it to Ok(None), since absence is not failure); an Integer is Ok(Some(n)); text that
parses is Ok(Some(n)); text that doesn’t parse is the only Err.
> output appears here — press Run
Show solution
fn coerce_to_int(v: &Value) -> Result<Option<i64>, CoercionError> { match v { Value::Null => Ok(None), // absent: clean, not an error Value::Integer(n) => Ok(Some(*n)), Value::Text(s) => match s.parse::<i64>() { Ok(n) => Ok(Some(n)), Err(_) => Err(CoercionError::ParseFailure { input: s.clone(), target: "Integer", }), }, }}This is the three-outcome type again, now produced by your code: Ok(None) for clean
absence, Ok(Some(n)) for a value, Err(e) for a real failure. That .clone() on s is
a deliberate, owned copy for the error message, the kind of clone the last lesson called
the right call, because the error needs to outlive the borrowed cell.
Real-source grounding
Section titled “Real-source grounding”Here is the trait method the worked example mirrors. The engine’s streaming reader returns the composed type on every call:
clinker-format ·traits.rs ·next_record fn @19acdcb4
fn next_record(&mut self) -> Result<Option<Record>, FormatError>;// Ok(Some(record)) → a row// Ok(None) → end of input (clean)// Err(e) → something went wrongWhat the compiler enforces: a caller can’t read a Record out of this without first
peeling the Result (handling Err) and then the Option (handling None). There is no
path to the inner Record that skips either check.
What a junior might misread: treating Ok(None) as an error condition (so a clean,
finite file looks like a failure), or treating Err(e) as “no more data” (so a malformed
line silently ends the job). The type makes both mistakes loud, but only if you read all
three arms.
And here is the typed error a coercion produces, the one your faded task modeled:
clinker-record ·coercion.rs ·CoercionError type @19acdcb4
pub enum CoercionError { TypeMismatch { from: &'static str, to: &'static str, value: String }, ParseFailure { input: String, target: &'static str },}The real coerce_to_int returns Result<Value, CoercionError>, and there’s a lenient
companion that returns an Option instead by throwing the error away:
clinker-record ·coercion.rs ·coerce_to_int fn @19acdcb4
// strict: the failure is a typed value you must handlepub fn coerce_to_int(value: &Value) -> Result<Value, CoercionError> { /* ... */ }
// lenient: same logic, but the error is discarded into Nonepub fn coerce_to_int_lenient(value: &Value) -> Option<Value> { coerce_to_int(value).ok() // .ok() turns Ok(v) -> Some(v), Err(_) -> None}That .ok() is the clearest possible statement of the difference between the two types: a
Result carries why it failed, where an Option only records that something is absent. The
strict path keeps the reason so the engine can put it in a dead-letter entry; the lenient
path is for callers that genuinely don’t care which way it went.
The ? operator: propagating errors without a staircase
Section titled “The ? operator: propagating errors without a staircase”Once functions return Result, you chain fallible steps constantly. Writing a full
match at every step would bury the logic.
🌱 New here? — the ? operator
The ? operator is one-character error propagation. Written after a Result-returning
call, let x = something()?; means: if the call is Err, return that Err from the
current function right here; if it’s Ok, unwrap the value and keep going. The current
function’s return type must itself be a Result (so there’s an Err to return into), and
? converts the error type along the way via the From trait. It’s the same early-return
you’d write by hand, compressed to a single character.
> output appears here — press Run
The engine’s fallible paths are threaded with ? end to end. You’ll meet the full error
vocabulary, how Clinker defines one error type that every step’s ? converts into, in
the error-handling lesson; this lesson only needs you to read ?
where it appears.
Why-bridge: recoverable vs fatal, and the DLQ
Section titled “Why-bridge: recoverable vs fatal, and the DLQ”Why does the engine bother splitting absence from failure so carefully? Because not every
error should kill a job. If one row in a million can’t be coerced ("active".to_int() is
nonsense), Clinker can route that single bad record to the dead-letter queue (the dlq
count you saw in --dry-run) and keep going, instead of aborting the whole run.
That decision rides on the type split you just learned:
- A data error on one row (a
CoercionError, or a malformed line surfaced asErr(FormatError)) is recoverable. It can be dead-lettered; the run continues. - An invariant error, a bug or an impossible state, is fatal. The run aborts.
The strict coerce_to_int returning Result (not the lenient Option) is what makes the
recoverable case carry its reason all the way to the dead-letter entry, so an operator
sees “row 42: failed to parse ‘active’ as Integer” rather than a silent drop.
Apply: predict, then repair
Section titled “Apply: predict, then repair”This is a generation task, not recognition. Read the function below. It’s meant to be
broken: it tries to use an Option as if it were the value inside it.
// WON'T COMPILE — kept here as a deliberate counterexample.fn lookup_id(present: bool) -> Option<i64> { if present { Some(7) } else { None }}
fn main() { let found = lookup_id(true); let next = found + 1; // <- error here println!("{next}");}Predict the compiler first. What error does let next = found + 1; produce, and why?
Show the compiler's answer
error[E0369]: cannot add `{integer}` to `Option<i64>` --> src/main.rs | | let next = found + 1; | ----- ^ - {integer} | | | Option<i64>found is an Option<i64>, not an i64. You can’t do arithmetic on “maybe a number”;
you must first handle the None case to get at the Some value. The fix makes the
absence explicit:
let next = match lookup_id(true) { Some(n) => n + 1, None => 0, // or return early, or propagate — but you must DECIDE};This is the entire safety promise of Option in one error message: the compiler will not
let you pretend an absent value is present. The same is true one layer up. You can’t read
a Record out of Result<Option<Record>, _> without peeling both wrappers.
Retrieval checkpoint
Section titled “Retrieval checkpoint”Connections
Section titled “Connections”You can now read absence and failure in the types, decompose the composed reader type into
its three outcomes, and write a function that produces it. Next: the small struct that
rides along with every record so the engine always knows where a row came from.
Go deeper on the Rust (optional, one-directional, for the same concepts taught from first principles in The Rust Book):
Glossary terms used: Option, Result, Result<Option<T>>, borrow.