Skip to content

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:

rust // editable

// quick check

What does drawing.get(5) return when the vector only has 2 elements?

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 present
let count = drawing.get(0).map(|_| 1).unwrap_or(0); // transform, with a fallback
  • if 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, leaving None as None.

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.

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 read-only exercise — use the playground.

Using the drawing vector, 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 using match or if 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.

  • Option is just null with extra steps.” The opposite: null lets you forget to check and crash later; Option makes 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 for match / if let / unwrap_or in code that must survive missing data — which is most engine code.

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.