Option, not null
In one sentence
Section titled “In one sentence”Rust has no null: a value that can be missing has the type Option<T>, which is Some(value) or None, and the compiler makes you handle both.
The Java you know
Section titled “The Java you know”In Java, any reference can be null. Optional<T> exists, but it is a convention for return values. Fields, parameters and collection elements can still be null, and the type does not tell you.
Java
Optional<Customer> find(long id) { return customers.stream().filter(c -> c.id() == id).findFirst();}Rust
pub fn find(&self, id: u64) -> Option<&Customer> { self.customers.iter().find(|c| c.id == id)}The two look the same. The difference is what the rest of the language does. In Java, Customer can also be null, and Optional itself can be null. In Rust, a &Customer always points to a customer. The only way to say “maybe no customer” is Option<&Customer>, and the only way to get the customer out is to handle None.
C# nullable reference types (Customer?) are closer: the compiler warns when you use a nullable value without a check. In Rust it is an error, not a warning, and there is no way to switch it off.
The Rust you will see
Section titled “The Rust you will see”Option<T> is a normal enum from the standard library:
Some(value): there is a value.None: there is no value.
It is used everywhere: return values, struct fields (nickname: Option<String>), parameters, and collection elements. Option<&T> costs nothing extra at run time: it has the same size as a pointer, with None stored as the null address. So there is no performance reason to avoid it.
Combinators: map, and_then, unwrap_or
Section titled “Combinators: map, and_then, unwrap_or”Like Optional, Option has methods that transform the value without an explicit check:
Java
String displayName(long id) { return find(id) .map(c -> c.nickname() != null ? c.nickname() : c.name()) // fields can still be null .orElse("unknown customer");}
Optional<String> referrerName(long id) { return find(id) .map(Customer::referrerId) // null becomes Optional.empty() .flatMap(this::find) .map(Customer::name);}Rust
pub fn display_name(&self, id: u64) -> String { self.find(id) .map_or("unknown customer", |c| { c.nickname.as_deref().unwrap_or(&c.name) }) .to_string()}
pub fn referrer_name(&self, id: u64) -> Option<&str> { self.find(id) .and_then(|c| c.referrer_id) // `None` if there is no customer or no referrer .and_then(|rid| self.find(rid)) .map(|r| r.name.as_str())}| Rust | Java Optional |
Meaning |
|---|---|---|
map(f) |
map(f) |
Transform the value, if there is one. |
and_then(f) |
flatMap(f) |
Call a function that also returns Option. |
unwrap_or(x) |
orElse(x) |
The value, or x if None. |
unwrap_or_else(f) |
orElseGet(f) |
The value, or the result of f(). |
unwrap_or_default() |
(none) | The value, or the type’s default (0, "", empty Vec). |
map_or(x, f) |
map(f).orElse(x) |
Transform, or use x. |
ok_or(err) |
orElseThrow(...) |
Convert to Result: Some(v) to Ok(v), None to Err(err). |
filter(p) |
filter(p) |
Keep the value only if p is true. |
unwrap() |
get(), orElseThrow() |
The value, or panic. |
How to read it
Section titled “How to read it”? on Option
Section titled “? on Option”In a function that returns Option, ? means “if this is None, return None now”. It replaces a chain of and_then calls:
pub fn referrer_name_with_q(&self, id: u64) -> Option<&str> { let customer = self.find(id)?; // `None`: return `None` now let referrer = self.find(customer.referrer_id?)?; Some(&referrer.name)}- Line 1: The function returns
Option, so?can returnNoneearly. - Line 2: No customer with this ID: return
None. Otherwisecustomeris a plain&Customer. - Line 3: Two
?on one line:referrer_idisOption<u64>, andfindreturnsOption<&Customer>. - Line 4: Wrap the result in
Some.&referrer.nameis a&String, and it converts to&str.
This version and the combinator version of referrer_name above do the same thing. Choose the one that is easier to read. For more than two steps, ? is usually clearer.
if let and let ... else
Section titled “if let and let ... else”To do something only when there is a value, use if let. To leave early when there is no value, use let ... else (stable since Rust 1.65):
pub fn greeting(&self, id: u64) -> String { if let Some(customer) = self.find(id) { format!("Hello, {}", customer.name) } else { "Hello".to_string() }}
pub fn require_name(&self, id: u64) -> Result<&str, UnknownCustomer> { let Some(customer) = self.find(id) else { return Err(UnknownCustomer(id)); }; Ok(&customer.name)}let Some(customer) = ... else { return ... }; reads as “the customer must exist; otherwise leave now”. After the statement, customer is a plain value. This is the idiomatic replacement for Java’s if (x == null) return ...; guard clause.
What good looks like
Section titled “What good looks like”- The type says it. If a value can be missing, its type is
Option<T>. If it cannot be missing, its type isT. There is no third case. - Return
Option<&T>from lookups, so the caller decides whether to copy. - Combinators or
?for short chains,matchorif letwhen each case has real work. let ... elsefor guard clauses, so the main path is not indented.ok_ororok_or_elseat the boundary where “missing” becomes an error, so the error says what was missing.- Empty collections instead of
None. A function that returns a list returns an emptyVecwhen there is nothing, notNone.
Red flags in AI output
Section titled “Red flags in AI output”.unwrap()without a reason. A panic in production when the value is missing. See S02:unwrap()/expect()in non-test code.is_some()followed byunwrap(). A translation ofif (x != null) { x.get() }. Useif let Some(x) = .... Clippy reports it (unnecessary_unwrap).Option<bool>with three states (Some(true),Some(false),None) and no comment that says whatNonemeans. Use an enum with named variants.Option<Option<T>>without a clear reason. Sometimes it is correct (a field that is “not set” vs “set to nothing” in a patch request), but it needs a comment.Option<Vec<T>>when an emptyVecmeans the same asNone. Every caller must handle two kinds of “nothing”.unwrap_or(expensive()): the argument is evaluated even when there is a value. Useunwrap_or_else(expensive), which calls the function only forNone. Clippy can report it (or_fun_call, in thenurserygroup, which is off by default).- Sentinel values instead of
Option:-1for “no index”,""for “no name”,0for “no ID”. The Java habit of magic values, in a language that has a type for it.
- S02Severity: High
unwrap()/expect()in non-test codeThe program stops (panics) on a failure that the caller could handle. - S17Severity: MediumStruct with many
Optionfields that are really statesWhich fields are set depends on a hidden state that an enum should express.
Better vs worse
Section titled “Better vs worse”The task: get the nickname of a customer, or the name if there is no nickname, or an empty string if there is no customer.
WorseJava null checks in Rust syntax:
is_some()thenunwrap(), twice, with early returns.pub fn nickname_or_name(dir: &Directory, id: u64) -> String {let customer = dir.find(id);if customer.is_some() {let c: &Customer = customer.unwrap();if c.nickname.is_some() {return c.nickname.clone().unwrap();}return c.name.clone();}String::new()}Run in Playground (opens the Rust Playground in a new tab) BetterOne chain: find, choose nickname or name, default to an empty string. No
unwrap.pub fn nickname_or_name(dir: &Directory, id: u64) -> String {dir.find(id).map(|c| c.nickname.as_ref().unwrap_or(&c.name).clone()).unwrap_or_default()}Run in Playground (opens the Rust Playground in a new tab)
The “worse” version is correct today. The risk is in the next change: an unwrap() is safe only because of a check that is three lines away. If someone moves or changes the check, the unwrap() panics, and the compiler does not warn. In the “better” version, there is no unwrap() to break.
And a lookup that returns a list:
Worse
NoneandSome(vec![])both mean "no tags", so every caller must handle both.pub fn tags_for(sku: &str) -> Option<Vec<String>> {if sku.starts_with("BK-") {Some(vec!["book".to_string()])} else {None}}Run in Playground (opens the Rust Playground in a new tab) BetterAn empty
Vecmeans "no tags". One case, andVec::new()does not allocate.pub fn tags_for(sku: &str) -> Vec<String> {if sku.starts_with("BK-") {vec!["book".to_string()]} else {Vec::new()}}Run in Playground (opens the Rust Playground in a new tab)
Option<Vec<T>> is correct when “not loaded” and “loaded, but empty” are different states that the caller must handle differently. Then a comment, or an enum, should say so.
Check yourself
Section titled “Check yourself”Can a
&Customerbe null in Rust?Show the answer
No. A reference always points to a valid value. “Maybe no customer” is
Option<&Customer>, and the compiler makes you handleNonebefore you use it.What is the Java equivalent of
and_then?Show the answer
Optional.flatMap. It calls a function that itself returns anOption, and does not nest the result.An agent wrote
if user.is_some() { let u = user.unwrap(); ... }. What do you suggest?Show the answer
if let Some(u) = user { ... }. It does the check and the extraction in one step, and there is nounwrap()that can panic after a later change.A function returns
Option<Vec<Order>>. What do you ask?Show the answer
What
Nonemeans that an emptyVecdoes not. If there is no difference, returnVec<Order>.What does
?do in a function that returnsOption<T>?Show the answer
If the value is
None, the function returnsNoneat once. If it isSome(v), the expression has the valuev, and the function continues.