Option — absence without null
The Rust question for this lesson: a lookup might not find anything — ask for the
10th item of a 3-item list, or a column that isn’t in the row. How do you represent
“maybe a value, maybe nothing” without null and without exceptions?
Many languages answer with null — and pay for it with crashes when something forgets
to check. Rust has no null. Instead, “maybe nothing” is an ordinary value of an
ordinary enum called Option — which means you already know how to take it apart
(match, lesson 2), and the compiler won’t let you use a maybe-missing value as if it
were definitely there.
Option is just an enum you already understand
Section titled “Option is just an enum you already understand”Option<T> is defined, in the standard library, as nothing more exotic than:
enum Option<T> { Some(T), // there is a value, and here it is None, // there is no value}That’s the enum shape from lesson 1: two variants, one carrying a payload, one not.
The <T> means it works for any type — Option<i32>, Option<&Shape>,
Option<String>. Because it’s an enum, you handle it with the same tools:
match drawing.get(1) { Some(shape) => println!("found {shape:?}"), None => println!("nothing there"),}A standard collection lookup already speaks Option: Vec::get(i) returns
Option<&T> — Some(&item) when i is in range, None when it isn’t. Run this and
watch an in-range and an out-of-range lookup behave differently — safely:
> output appears here — press Run
// quick check
What does drawing.get(5) return when the vector only has 2 elements?
Out-of-range access through get returns None. There's no null and no crash — the absence is a value you handle.
Handling ‘nothing’ without writing match every time
Section titled “Handling ‘nothing’ without writing match every time”A full match is always available, but for common cases there are shorthands:
let shape = drawing.get(0); // Option<&Shape>
if let Some(s) = shape { /* use s */ } // act only when presentlet count = drawing.get(0).map(|_| 1).unwrap_or(0); // transform, with a fallbackif let Some(x) = ...— run a block only when there’s a value..unwrap_or(default)— the value if present, otherwise a fallback you supply..map(...)— transform the inner value if there is one, leavingNoneasNone.
There’s also .unwrap() / .expect("..."), which extract the value but panic
(crash) on None. They’re fine in throwaway code and tests; in engine code that must
not crash on bad input, you handle the None explicitly instead.
The same idea, in the engine
Section titled “The same idea, in the engine”Clinker is full of lookups that might come up empty, and every one returns Option.
The simplest is positional cell access on a record — ask for a cell by index:
clinker-record ·minimal.rs ·MinimalRecord type @47d2e12
pub fn get(&self, index: usize) -> Option<&Value> { self.fields.get(index)}It’s the Vec::get you just used, surfaced on a record: Some(&value) if that column
exists, None if the index is past the end. One level up, fields are looked up by
name through a trait — and it returns Option for the same reason:
clinker-record ·resolver.rs ·FieldResolver trait @47d2e12
pub trait FieldResolver { /// Unqualified field lookup: `field_name` → `&Value`. fn resolve(&self, name: &str) -> Option<&Value>; // ...}resolve("price") gives Some(&value) if the row has a price field and None if it
doesn’t. In real ETL, missing fields are routine — files have ragged columns, sources
disagree on schema — so “this field might not be here” is something the engine must
confront on every row. Option makes that un-ignorable: a caller literally cannot
read the value without first dealing with the None case.
(One thing to notice for later: both return Option<&Value> — a borrow of the value,
written &Value, not a copy. Why it borrows rather than hands over ownership is the
subject of the next phase. For now, just read &Value as “a look at the value that
lives inside the record.”)
// quick check
Why do clinker's field lookups return Option<&Value> instead of just &Value?
A field may simply not exist on a given row. Returning Option makes 'absent' an explicit case the caller must handle — no null, no silent misread.
Your turn
Section titled “Your turn”A read-only exercise — use the playground.
Using the
drawingvector, write one line that prints the debug form of the shape at index 1, or the text"(empty slot)"if there’s nothing there — without usingmatchorif let, and without any chance of a panic.
💡 Hint 1
You want “transform the value if present, otherwise a fallback.” That’s .map(...) to
build the string when there’s a value, then .unwrap_or(...) to supply the fallback
when there isn’t.
Show solution
let label = drawing.get(1) .map(|s| format!("{s:?}")) .unwrap_or_else(|| "(empty slot)".to_string());println!("{label}");.map runs only when there’s a value (turning &Shape into a String); .unwrap_or_else
supplies the fallback when it’s None. No match, no if let, and no .unwrap() that
could panic — the absence is handled by construction.
Common misconceptions
Section titled “Common misconceptions”- “
Optionis justnullwith extra steps.” The opposite:nulllets you forget to check and crash later;Optionmakes the compiler require the check before you can touch the value. - “
.unwrap()is the normal way to get the value out.” It’s the crash-on-absent way. Reach formatch/if let/unwrap_orin code that must survive missing data — which is most engine code.
Where this leads
Section titled “Where this leads”You’ve now met the three ideas that let you read most of clinker’s data layer: enums
(what a value is), match (handling each kind), and Option (handling absence). You
may have noticed both lookups returned a borrow — &Value — rather than handing
over the value itself. Who owns the data, and what a borrow really is, is the heart of
Rust and the start of the next phase: ownership and borrowing.