Skip to content

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.

  • Read Option<T> and Result<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.

Before composing anything, look at each half on a real value.

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:

rust // editable

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.

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.

rust // editable

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:

  1. Is there a next record, or has the finite source run out? → that’s an Option: Some(record) or None.
  2. Did the attempt to read it succeed, or did it hit a malformed line? → that’s a Result: Ok(...) or Err(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.

Now you read and write the composed type, scaffolded down to your own.

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.

rust // editable

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.

rust // editable
💡 Hint 1
An 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.

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.

rust // editable
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.

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 wrong

What 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 handle
pub fn coerce_to_int(value: &Value) -> Result<Value, CoercionError> { /* ... */ }
// lenient: same logic, but the error is discarded into None
pub 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.

rust // editable

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 as Err(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.

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.

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.