Learn Rust Series (#47) - Option Combinators & Null-Free Programming
Published on HivePostify by @scipio · Fri Sep 04 2026
Learn Rust Series (#47) - Option Combinators & Null-Free Programming
What will I learn - You will learn how to transform and chain Option values without writing a single match; - how map, andthen, and filter build clean option pipelines that short-circuit on absence; - how okor and okorelse promote a missing value into a Result error you can propagate; - how the ? operator works on Option for early returns, and how take/asref/zip round out the toolkit; - how Rust's Option eliminates the null-pointer mistakes that have plagued other languages for half a century.
Requirements - A working modern computer running macOS, Windows or Ubuntu; - An installed Rust toolchain (via rustup, from rustup.rs); - The previous forty-six episodes, especially error handling (episode 6) and the Result combinators (episode 46); - The ambition to learn systems programming from the ground up.
Difficulty - Intermediate
Curriculum (of the Learn Rust Series): - [Learn Rust Series (#1) - Introduction to Rust](https://hive.blog/hive-196387/@scipio/learn-rust-series-1-introduction-to-rust) - [Learn Rust Series (#2) - Variables, Types, Functions](https://hive.blog/hive-196387/@scipio/learn-rust-series-2-variables-types-functions) - [Learn Rust Series (#3) - Ownership & Borrowing](https://hive.blog/hive-196387/@scipio/learn-rust-series-3-ownership-borrowing) - [Learn Rust Series (#4) - Control Flow & Pattern Matching](https://hive.blog/hive-196387/@scipio/learn-rust-series-4-control-flow-pattern-matching) - [Learn Rust Series (#5) - Structs & Enums](https://hive.blog/hive-196387/@scipio/learn-rust-series-5-structs-enums) - [Learn Rust Series (#6) - Error Handling](https://hive.blog/hive-196387/@scipio/learn-rust-series-6-error-handling) - [Learn Rust Series (#7) - Collections](https://hive.blog/hive-196387/@scipio/learn-rust-series-7-collections) - [Learn Rust Series (#8) - Traits & Generics](https://hive.blog/hive-196387/@scipio/learn-rust-series-8-traits-generics) - [Learn Rust Series (#9) - Modules & Crates](https://hive.blog/hive-196387/@scipio/learn-rust-series-9-modules-crates) - [Learn Rust Series (#10) - Lifetimes](https://hive.blog/hive-196387/@scipio/learn-rust-series-10-lifetimes) - [Learn Rust Series (#11) - Closures & the Iterator Trait](https://hive.blog/hive-196387/@scipio/learn-rust-series-11-closures-the-iterator-trait) - [Learn Rust Series (#12) - Smart Pointers: Box, Rc & RefCell](https://hive.blog/hive-196387/@scipio/learn-rust-series-12-smart-pointers-box-rc-refcell) - [Learn Rust Series (#13) - Concurrency: Threads, Channels, Arc & Mutex](https://hive.blog/hive-196387/@scipio/learn-rust-series-13-concurrency-threads-channels-arc-mutex) - [Learn Rust Series (#14) - Mini Project: A Command-Line To-Do App](https://hive.blog/hive-196387/@scipio/learn-rust-series-14-mini-project-a-command-line-to-do-app) - [Learn Rust Series (#15) - Trait Objects & Dynamic Dispatch](https://hive.blog/hive-196387/@scipio/learn-rust-series-15-trait-objects-dynamic-dispatch) - [Learn Rust Series (#16) - Static vs Dynamic Dispatch](https://hive.blog/hive-196387/@scipio/learn-rust-series-16-static-vs-dynamic-dispatch) - [Learn Rust Series (#17) - Associated Types vs Generic Parameters](https://hive.blog/hive-196387/@scipio/learn-rust-series-17-associated-types-vs-generic-parameters) - [Learn Rust Series (#18) - Operator Overloading with std::ops](https://hive.blog/hive-196387/@scipio/learn-rust-series-18-operator-overloading-with-stdops) - [Learn Rust Series (#19) - Deref, DerefMut & Deref Coercion](https://hive.blog/hive-196387/@scipio/learn-rust-series-19-deref-derefmut-deref-coercion) - [Learn Rust Series (#20) - Drop & Deterministic Destruction (RAII)](https://hive.blog/hive-196387/@scipio/learn-rust-series-20-drop-deterministic-destruction-raii) - [Learn Rust Series (#21) - From, Into, TryFrom & Idiomatic Conversions](https://hive.blog/hive-196387/@scipio/learn-rust-series-21-from-into-tryfrom-idiomatic-conversions) - [Learn Rust Series (#22) - Deriving Common Traits](https://hive.blog/hive-196387/@scipio/learn-rust-series-22-deriving-common-traits) - [Learn Rust Series (#23) - The Orphan Rule & Trait Coherence](https://hive.blog/hive-196387/@scipio/learn-rust-series-23-the-orphan-rule-trait-coherence) - [Learn Rust Series (#24) - Blanket Implementations & the Newtype Pattern](https://hive.blog/hive-196387/@scipio/learn-rust-series-24-blanket-implementations-the-newtype-pattern) - [Learn Rust Series (#25) - Marker Traits: Sized, Send, Sync & Copy](https://hive.blog/hive-196387/@scipio/learn-rust-series-25-marker-traits-sized-send-sync-copy) - [Learn Rust Series (#26) - Const Generics: Types That Depend on Values](https://hive.blog/hive-196387/@scipio/learn-rust-series-26-const-generics-types-that-depend-on-values) - [Learn Rust Series (#27) - Generic Associated Types & Lending Iterators](https://hive.blog/hive-196387/@scipio/learn-rust-series-27-generic-associated-types-lending-iterators) - [Learn Rust Series (#28) - Sealed Traits & Designing Stable APIs](https://hive.blog/hive-196387/@scipio/learn-rust-series-28-sealed-traits-designing-stable-apis) - [Learn Rust Series (#29) - Typestate Programming: State Machines in the Type System](https://hive.blog/hive-196387/@scipio/learn-rust-series-29-typestate-programming-state-machines-in-the-type-system) - [Learn Rust Series (#30) - Mini Project: A Generic Units-of-Measure Library](https://hive.blog/hive-196387/@scipio/learn-rust-series-30-mini-project-a-generic-units-of-measure-library) - [Learn Rust Series (#31) - Move Semantics Deep Dive](https://hive.blog/hive-196387/@scipio/learn-rust-series-31-move-semantics-deep-dive) - [Learn Rust Series (#32) - Interior Mutability: Cell & RefCell](https://hive.blog/hive-196387/@scipio/learn-rust-series-32-interior-mutability-cell-refcell) - [Learn Rust Series (#33) - Rc Internals: Reference Counting & Shared Ownership](https://hive.blog/hive-196387/@scipio/learn-rust-series-33-rc-internals-reference-counting-shared-ownership) - [Learn Rust Series (#34) - Arc: Thread-Safe Reference Counting & Its Cost](https://hive.blog/hive-196387/@scipio/learn-rust-series-34-arc-thread-safe-reference-counting-its-cost) - [Learn Rust Series (#35) - Weak References & Breaking Reference Cycles](https://hive.blog/hive-196387/@scipio/learn-rust-series-35-weak-references-breaking-reference-cycles) - [Learn Rust Series (#36) - Cow: Clone-on-Write for Borrow-or-Own APIs](https://hive.blog/hive-196387/@scipio/learn-rust-series-36-cow-clone-on-write-for-borrow-or-own-apis) - [Learn Rust Series (#37) - Pin & Self-Referential Structs](https://hive.blog/hive-196387/@scipio/learn-rust-series-37-pin-self-referential-structs) - [Learn Rust Series (#38) - PhantomData, Zero-Sized Types & Marker Lifetimes](https://hive.blog/hive-196387/@scipio/learn-rust-series-38-phantomdata-zero-sized-types-marker-lifetimes) - [Learn Rust Series (#39) - Variance: Covariance, Contravariance & Why It Matters](https://hive.blog/hive-196387/@scipio/learn-rust-series-39-variance-covariance-contravariance-why-it-matters) - [Learn Rust Series (#40) - Arena & Bump Allocation Patterns](https://hive.blog/hive-196387/@scipio/learn-rust-series-40-arena-bump-allocation-patterns) - [Learn Rust Series (#41) - Building Your Own Smart Pointer](https://hive.blog/hive-196387/@scipio/learn-rust-series-41-building-your-own-smart-pointer) - [Learn Rust Series (#42) - Drop Order, the Drop Check & Leak Safety](https://hive.blog/hive-196387/@scipio/learn-rust-series-42-drop-order-the-drop-check-leak-safety) - [Learn Rust Series (#43) - std::mem: swap, replace, take & forget](https://hive.blog/hive-196387/@scipio/learn-rust-series-43-stdmem-swap-replace-take-forget) - [Learn Rust Series (#44) - Higher-Ranked Trait Bounds & Lifetime Elision](https://hive.blog/hive-196387/@scipio/learn-rust-series-44-higher-ranked-trait-bounds-lifetime-elision) - [Learn Rust Series (#45) - Mini Project: A Doubly-Linked List, Safe then Unsafe](https://hive.blog/hive-196387/@scipio/learn-rust-series-45-mini-project-a-doubly-linked-list-safe-then-unsafe) - [Learn Rust Series (#46) - Result Combinators: map, maperr, andthen, okor](https://hive.blog/hive-196387/@scipio/learn-rust-series-46-result-combinators-map-maperr-andthen-okor) - [Learn Rust Series (#47) - Option Combinators & Null-Free Programming](https://hive.blog/hive-196387/@scipio/learn-rust-series-47-option-combinators-null-free-programming) (this post)
Learn Rust Series (#47) - Option Combinators & Null-Free Programming
Last episode we spent a long, happy afternoon with Result and its combinators, and I closed by promising that Option had a whole ergonomic toolkit of its own that deserved its own post. Here we are ;-) The good news is that if episode 46 clicked, this one is largely recognition rather than fresh learning: Option and Result are cousins, and their combinators rhyme almost method for method. map is map, andthen is andthen, unwrapor is unwrapor. What is genuinely new is the why underneath it all -- because Option is Rust's answer to the single most expensive mistake in the history of programming languages, and understanding that mistake is what turns these little methods from "nice syntax" into "a category of crash that simply cannot happen in your code".
Having said that, before we look forward we owe episode 46 its homework. I left three exercises on the Result combinators, and skipping the solutions would be cheating you.
Solutions to Episode 46 Exercises
Episode 46 was all about map, maperr, andthen, and the bridges between Result and Option. Here is full, runnable code for each exercise -- the whole program, not a fragment, so you can paste and run it.
Exercise 1 asked you to parse a &str to f64, use maperr to turn a parse failure into a custom message, and use map to round the result to the nearest integer, all in one chain returning Result : rust fn parseround(s: &str) -> Result { s.parse:: () .maperr(|| format!("'{s}' is not a number")) .map(|f| f.round() as i64) }
fn main() { println!("{:?}", parseround("3.7")); // Ok(4) println!("{:?}", parseround("-2.4")); // Ok(-2) println!("{:?}", parseround("nope")); // Err("'nope' is not a number") }
The key insight is the order: maperr first reshapes the failure path into our own vocabulary, and map then reshapes the success path (round, then cast). Because map never touches an Err, the "nope" case flows straight past it untouched. One chain, two independent transformations, no match.
Exercise 2 wanted two fallible steps chained with andthen: first parse a &str to a usize, then use that as a key into a small HashMap , returning okor "no such key" when the lookup misses: rust use std::collections::HashMap;
fn lookup(s: &str, table: &HashMap ) -> Result { s.parse:: () .maperr(|| format!("'{s}' is not an index")) .andthen(|i| { table.get(&i) .map(|word| word.tostring()) .okor(format!("no such key {i}")) }) }
fn main() { let table: HashMap = [(0, "zero"), (1, "one"), (2, "two")].intoiter().collect(); println!("{:?}", lookup("2", &table)); // Ok("two") println!("{:?}", lookup("9", &table)); // Err("no such key 9") println!("{:?}", lookup("x", &table)); // Err("'x' is not an index") }
Notice the two different failure modes producing two different errors. A bad string fails at the parse/maperr stage; a valid index that is simply absent fails at the okor inside the andthen. And HashMap::get returns an Option, which is exactly the Option-to-Result bridge that okor exists to cross -- the same bridge that is one of the stars of this episode.
Exercise 3 was the collect trick: given a Vec of numbers where one entry is garbage, use .collect:: , >>() to show it short-circuits to an Err, then swap the garbage for a valid number and show it becomes Ok: rust fn main() { let garbage = ["1", "2", "x", "4"]; let r: Result , > = garbage.iter().map(|s| s.parse:: ()).collect(); println!("is err? {}", r.iserr()); // true -- it stopped at "x"
let clean = ["1", "2", "3", "4"]; let r2: Result , > = clean.iter().map(|s| s.parse:: ()).collect(); println!("{r2:?}"); // Ok([1, 2, 3, 4]) }
collect sees an iterator of Results and, thanks to a clever FromIterator impl, hands back a single Result , > -- Ok with every value if all succeed, or the first Err the instant one fails. A whole-batch validator in a single word. Right, homework cleared. Now, Option.
The billion-dollar mistake
Let me set the stage properly, because it matters. In 1965 Tony Hoare introduced the null reference into ALGOL W, and decades later he called it his "billion-dollar mistake" -- an offhand invention that has since caused an uncountable pile of crashes, security holes, and 2 a.m. pages. The problem is not the concept of "a value might be absent"; that is legitimate and everywhere. The problem is that in most languages, every reference is secretly nullable. A String in Java, an object pointer in C, a reference in older C#: any of them can silently be null, and the type system says nothing. You find out when you dereference it and the whole thing falls over.
Rust simply does not have null. In stead of every reference secretly being able to be absent, absence is made an explicit, visible thing in the type: a value that might not be there has type Option , which is nothing more than a plain enum with two variants -- the standard library defines it essentially as enum Option { None, Some(T) }, and it lives in the prelude so you never even import it: rust // Conceptually, std defines: enum Option { None, Some(T) } fn main() { let present: Option = Some(5); let absent: Option = None; println!("{present:?} {absent:?}"); // Some(5) None }
That is the whole trick, and it is enormous. Because the possibility of absence is now in the type, the compiler forces you to handle the None case before you can ever touch the inner value. There is no way to "accidentally" use an absent value, because Option is not an i32 -- you cannot add it, index with it, or print it as a number until you have dealt with the None. An entire class of crash, the null-pointer dereference, is designed out of existence at compile time. The combinators are simply the ergonomic way to do that handling, so that "deal with the None" does not mean a match on every line.
map, andthen, and filter
The core three transform an Option without you ever unwrapping it by hand. map applies a function to the inner value if it is Some. andthen chains a step that itself returns an Option and flattens the result. filter keeps a Some only when a predicate passes, otherwise collapses it to None: rust fn main() { let some: Option = Some(5); println!("{:?}", some.map(|n| n 2)); // Some(10)
let nested: Option > = Some(Some(3)); println!("{:?}", nested.andthen(|inner| inner)); // Some(3), flattened
println!("{:?}", Some(4).filter(|n| n % 2 == 0)); // Some(4) println!("{:?}", Some(3).filter(|n| n % 2 == 0)); // None -- predicate failed }
A None flows through all three of them completely untouched, and that is the property that makes chains work. Because absence short-circuits, you can stack a dozen of these methods and the moment any step produces None, every step after it is skipped and the whole expression is None. The same distinction from last episode applies here too, and it is worth burning in: use map when your closure returns a plain value T, and use andthen when your closure returns an Option . Reach for map where you should have used andthen and you get an ugly Option >; that nested box is the compiler telling you to switch tools.
Here is andthen earning its keep in a small chain where each step can genuinely fail: rust fn halfifeven(n: i32) -> Option { if n % 2 == 0 { Some(n / 2) } else { None } }
fn main() { println!("{:?}", Some(8).andthen(halfifeven).andthen(halfifeven)); // Some(2) println!("{:?}", Some(6).andthen(halfifeven).andthen(halfifeven)); // None }
Trace the second line: 8 halves to 4, which halves to 2, so we get Some(2). But 6 halves to 3, and 3 is odd, so the second halfifeven returns None -- and because absence short-circuits, we get None with no further work. This is the exact same shape as the Result pipeline from episode 46, just without an error payload riding along.
Fallbacks and the bridge to Result
The same fallback family you met on Result applies verbatim to Option: unwrapor supplies a fixed default, unwraporelse computes one lazily, and unwrapordefault uses the type's Default. And the star of the episode title, okor, is the bridge that promotes a None into a real Err you can propagate: rust fn main() { let present: Option = Some(7); let absent: Option = None;
println!("{}", present.unwrapor(0)); // 7 println!("{}", absent.unwrapor(0)); // 0 println!("{}", absent.unwraporelse(|| 1 + 1)); // 2, computed only because it was None
let r1: Result = present.okor(String::from("missing")); // Ok(7) let r2: Result = absent.okor(String::from("missing")); // Err("missing") println!("{r1:?} {r2:?}"); }
okor is the exact counterpart to Result::ok from last episode: ok drops an error to demote a Result into an Option, and okor attaches an error to promote an Option into a Result. You will cross this bridge constantly, because half of the standard library speaks Option -- HashMap::get, Vec::first, slice::get, Iterator::next -- and the moment you want one of those "not found" answers to become a propagatable error, okor (fixed error) or okorelse (error computed only when needed) is how you do it. As always, prefer the else variant when building the error costs something, so you do not pay for a message you never use.
The ? operator on Option
Just as ? propagates errors early in a function returning Result, it propagates absence early in a function returning Option. If the value is Some, ? unwraps it and carries on; if it is None, the function returns None right there: rust fn firstcharupper(s: &str) -> Option { let c = s.chars().next()?; // empty string -> return None here Some(c.toasciiuppercase()) }
fn main() { println!("{:?}", firstcharupper("hello")); // Some('H') println!("{:?}", firstcharupper("")); // None }
Without ? you would write a match or an if let just to pull the char out; with it, the happy path stays flat and the absent path exits cleanly. And because ? composes, you can thread several fallible-because-absent steps through one function and let the first missing piece bail out for you: rust fn ratio(s: &str) -> Option { let mut parts = s.split('/'); let num: f64 = parts.next()?.trim().parse().ok()?; let den: f64 = parts.next()?.trim().parse().ok()?; if den == 0.0 { return None; } Some(num / den) }
fn main() { println!("{:?}", ratio("10 / 4")); // Some(2.5) println!("{:?}", ratio("10 / 0")); // None -- guarded against divide-by-zero println!("{:?}", ratio("oops")); // None -- no '/', so the second next() is None }
Look at how much is going on here with almost no ceremony. Each parts.next()? bails if a piece of the string is missing, and each .parse().ok()? converts a parse Result into an Option with ok and then propagates absence with ?. Four ways to fail -- missing numerator, missing denominator, unparseable number, zero denominator -- and every one of them returns a tidy None without a single match.
In-place helpers: take, getorinsert, asref
Option also carries a set of methods that mutate in place, and they tie straight back to the std::mem toolkit from episode 43. take swaps the value out and leaves None behind, handing you ownership of what was there; getorinsert fills a None with a value and returns a mutable reference to whatever is now inside; and asref lets you look without moving: rust fn main() { let text: Option = Some(String::from("hello")); let len: Option = text.asref().map(|s| s.len()); // borrow, do not move println!("{len:?} still have {text:?}"); // Some(5) still have Some("hello")
let mut slot: Option = Some(String::from("data")); let taken = slot.take(); // slot is now None; taken owns the String println!("taken {taken:?}, slot {slot:?}"); // taken Some("data"), slot None
let mut cached: Option = None; let value = cached.getorinsert(42); // was None, now Some(42) value += 1; println!("{cached:?}"); // Some(43) }
That asref is the unsung hero of the bunch. Without it, text.map(|s| s.len()) would move the String out of text, leaving it unusable afterward -- asref turns an &Option into an Option so you can inspect the inner value and keep the original intact. It is the small move that keeps ownership errors off your back when you only wanted to peek. And take is precisely the pattern we leaned on when building linked structures back in episode 45: pull a value out of a field, leaving a valid None in its place, with never an invalid intermediate state.
A realistic pipeline
Put the pieces together and optional logic reads straight down the page. Looking up a score, checking it passes, and turning it into a grade with a default becomes one honest expression: rust use std::collections::HashMap;
Tags: #stem#stemsocial#steemstem#rust#programming