Option: absence as data
A citizen with no land-record entry is not a failure. The record may not exist yet, or the scheme may not need it — but null cannot tell “legitimately absent” apart from “someone forgot to set this field.” Option<T> makes absence a value with a type: Some<T> or None, and the compiler forces callers to handle both.
Yes, Optional exists. We build Option anyway — to see its implementation, to test its laws, and to give the library one consistent API (fold, Fn1-based mappers) across all its types. Part 19 covers when to use which.
The type
public sealed interface Option<T> {
<R> Option<R> map(Fn1<? super T, ? extends R> mapper);
<R> Option<R> flatMap(Fn1<? super T, ? extends Option<R>> mapper);
Option<T> filter(Predicate<? super T> predicate);
<R> R fold(Supplier<? extends R> whenNone, Fn1<? super T, ? extends R> whenSome);
T orElse(T fallback);
T orElseGet(Supplier<? extends T> fallback);
static <T> Option<T> some(T value) { return new Some<>(Objects.requireNonNull(value, "value must not be null")); }
static <T> Option<T> none() { return new None<>(); }
static <T> Option<T> ofNullable(T value) { return value == null ? none() : some(value); }}Some and None are records nested in the interface, so the sealed hierarchy closes itself — the permits list is inferred from the same file. some rejects nulls deliberately: an Option containing null recreates the exact ambiguity the type exists to remove.
The two implementations
record Some<T>(T value) implements Option<T> {
@Override public <R> Option<R> map(Fn1<? super T, ? extends R> mapper) { return Option.some(mapper.apply(value)); }
@Override public <R> Option<R> flatMap(Fn1<? super T, ? extends Option<R>> mapper) { return mapper.apply(value); }
@Override public Option<T> filter(Predicate<? super T> predicate) { return predicate.test(value) ? this : Option.none(); }
@Override public <R> R fold(Supplier<? extends R> whenNone, Fn1<? super T, ? extends R> whenSome) { return whenSome.apply(value); } // orElse/orElseGet return value}
record None<T>() implements Option<T> {
@Override public <R> Option<R> map(Fn1<? super T, ? extends R> mapper) { return Option.none(); }
@Override public <R> Option<R> flatMap(Fn1<? super T, ? extends Option<R>> mapper) { return Option.none(); }
@Override public Option<T> filter(Predicate<? super T> predicate) { return this; }
@Override public <R> R fold(Supplier<? extends R> whenNone, Fn1<? super T, ? extends R> whenSome) { return whenNone.get(); } // orElse/orElseGet return the fallback}The asymmetry is the lesson: None.map does not call the mapper at all. Mapping over an absent value is a no-op, not an error — that is what makes chains of map safe to write without defensive null checks between steps.
Scheme example: optional land evidence
// an existing evidence store returns null when no record exists — wrap at the boundaryOption<LandRecord> landRecord = Option.ofNullable(landRecords.get(citizenId));
Option<LandCategory> category = landRecord.map(LandRecord::landCategory);
String outcome = landRecord.fold( () -> "no land record on file", record -> "land category " + record.landCategory());fold is the exit door: it collapses both cases to one type at the boundary where you must produce a response. Inside the pipeline you stay in Option; at the edge you fold out.
Choosing between Option, Result, and exceptions
- Absent is a normal outcome →
Option(no land record yet). - Absent carries a reason callers need →
Result(Part 6) — a registry timeout is not the same as “not found,” and the type should say so. - The situation is genuinely unrecoverable — corrupted state, contract violation → an exception. Expected outcomes in disguised as exceptions make control flow invisible.
Law preview: Option obeys the functor and monad laws — map(identity) = identity, and some(a).flatMap(f) = f(a). Parts 7 and 10 test them for real; for now, hold the intuition that map never changes whether a value is present, only what a present value is.