Two sentences you will hear from people learning Rust, both wrong, and both expensive:
-
“
Optionis Rust’s null.” -
“
Resultis Rust’s checked exception.”
If you carry either of those around for a few months, you will write awkward
Rust and blame the language. This article is the reset. It costs ten minutes
and it makes everything downstream — combinators, ?, error traits — stop
looking like magic.
The actual definitions
Here they are, near enough verbatim from the standard library:
pub enum Option<T> {
None,
Some(T),
}
pub enum Result<T, E> {
Ok(T),
Err(E),
}
That is it. Two ordinary enums, defined in ordinary Rust, in a file you can open. Nothing about them is built into the compiler. You could write them yourself in six lines:
enum Maybe<T> { Nothing, Just(T) }
enum Outcome<T, E> { Fine(T), Broken(E) }
and everything except the ecosystem’s cooperation would work. They are in the
prelude — that is the only special treatment. (? does lean on a trait they
implement, but the trait is public and the enums are not privileged. More on
that later in the track.)
Once you believe this, several things follow immediately:
-
mapis not a keyword. It is a method someone wrote inimpl<T> Option<T>. You could write it yourself and it would be four lines. -
matchis not “the Option syntax”. It is the general way to take any enum apart, and it works on yours identically. -
There is nothing to “turn off”. No mode, no exception table, no runtime
cost that a flag could remove. An
Option<T>is a tagged union, and for types with a spare bit pattern — references,Box,NonZeroU32— the tag costs nothing at all:size_of::<Option<&T>>() == size_of::<&T>().
💡If Option<T> is a tagged union, you would expect it to be bigger than T — one word for the tag plus the payload. Why is Option<&T> the same size as &T?
click to reveal
Because a reference in Rust can never be null. That means the bit pattern
0x0 is unused for &T, and the compiler is free to use it as the None
tag. The optimisation is called niche filling: whenever a type has invalid
bit patterns going spare, Option (and any other enum with one payload-free
variant) moves in.
&T, &mut T, Box<T>, NonZeroU32, char and function pointers all have
niches. Option<Box<T>> is one pointer wide, so the classic “nullable
pointer” costs exactly what it costs in C — but with the null check enforced
by the type system instead of by your discipline.
Option<i32> does not get this, because every 32-bit pattern is a valid
i32. It is 8 bytes: 4 for the payload, 4 for the tag plus alignment padding.
Still free at runtime; just not free in space.
What is actually different from null
Tony Hoare called null references his “billion-dollar mistake”, and the reason
is not that null exists — it is that null is a value of every type. In Java,
String s might be a string, or might be nothing, and there is no syntax to
say which you meant. Every method call is a bet.
Rust splits the two apart:
let name: String = get_name(); // definitely a String
let name: Option<String> = get_name(); // maybe a String, maybe nothing
Those are different types. You cannot pass one where the other is wanted;
you cannot call String methods on the second without first saying what should
happen when it is empty. The compiler is not being pedantic — it genuinely
cannot know what you want, and neither could the reader.
The payoff is not “no more null pointer exceptions” (though it is that). The payoff is that the signature tells you. When you see
fn find_user(id: u64) -> Option<User>
you know, without reading the body, the docs or the tests, that this can come back empty, and that the compiler will make you deal with it. When you see
fn current_user() -> User
you know it cannot.
💡Java has Optional<T>, and TypeScript has string | undefined. If those exist, what does Rust actually gain?
click to reveal
Two things, and the second is the big one.
First, there is no back door. Java’s Optional<String> can itself be
null, because it is still a reference type. Optional.of(x) where x is null
throws. The type system is layered on top of a value that already contradicts
it. Rust has no null at all, so Option<Option<T>> means exactly what it
says and nothing else can sneak in.
Second, exhaustiveness. TypeScript’s union types are genuinely good, and
strictNullChecks gets you most of the way. What you do not get is the
guarantee across change: add a variant to a Rust enum and every match that
has not been updated stops compiling, today, everywhere, including in code you
forgot existed. That property is what makes large refactors mechanical rather
than terrifying, and it applies to every enum you write, not just to Option.
The honest summary is that the null-safety part is table stakes in 2026, and the exhaustive-enum part is the thing people actually miss when they go back.
What is actually different from exceptions
An exception is invisible control flow. Any line can throw; the handler is somewhere else; the type system usually says nothing. Java’s checked exceptions were an attempt to fix that, and they failed for a reason worth understanding: they were bolted onto methods rather than values, so they could not be stored, returned, mapped over, or collected. You could only catch them, and the cheapest catch is an empty one.
Result is a value.
fn parse_port(s: &str) -> Result<u16, ParseIntError>
The failure is not a side channel — it is half the return type. Which means every ordinary thing you can do with a value, you can do with a failure:
let results: Vec<Result<u16, _>> = inputs.iter().map(|s| parse_port(s)).collect();
let successes: Vec<u16> = results.into_iter().flatten().collect();
Store them in a vector. Send one down a channel. Keep one in a struct. Turn a list of results into a result of a list. None of that has an equivalent with exceptions, because an exception is not a thing you have, it is a thing that happens to you.
The other half of the difference is that you cannot ignore it silently.
Result is marked #[must_use], so dropping one is a warning — and in this
course’s grading, a build failure. The equivalent of an empty catch block has
to be written out loud:
let _ = might_fail(); // "yes, I looked at this and chose to ignore it"
A reviewer can grep for that. Nobody can grep for a catch you never wrote.
💡Exceptions have one genuine advantage: they unwind automatically, so a failure deep in a call stack does not need every intermediate function to mention it. Doesn't Result force you to thread errors through by hand at every level?
click to reveal
It would, and that is exactly the complaint people had about Go’s
if err != nil — which really does make you write the plumbing out.
Rust’s answer is the ? operator, which you will meet properly in a few
items. It turns “unwrap this or return the error from the enclosing function”
into one character, and — crucially — converts the error type on the way out,
so an intermediate function does not have to know about every error type
beneath it. The result reads about as clean as exception-based code:
fn load() -> Result<Config, ConfigError> {
let text = read_file("config.toml")?;
let port = parse_port(&text)?;
Ok(Config { port })
}
Two failure paths, no if, no catch, and the signature still tells you the
truth. The intermediate functions do have to mention that they can fail —
that is the deliberate part, because “this function can fail” is information
callers want. What they do not have to do is handle it.
Rust does also have unwinding, incidentally: that is what panic! does. It is
reserved for bugs rather than for expected failures, which is a distinction
this track comes back to.
The one thing to be careful about right now
You will very quickly meet this:
let n: i32 = maybe_number.unwrap();
unwrap takes the Some out, and panics — crashes the program — if there
is nothing there. It exists because sometimes you genuinely know, and
because examples need to be short.
Treat it, for now, as a placeholder that means “I have not decided what to do
here yet.” Every use of it in a code sample you find online is a decision the
author deferred, usually to keep the example readable. There is a whole item
later in this track about when it is legitimate and what a good expect
message says. Until then: if you find yourself typing .unwrap(), the
interesting question is what should happen in the empty case, and the answer
is almost never “crash”.
💡Here is a signature: fn config_dir() -> Option<PathBuf>. You are writing a command-line tool and you need that directory. What are your options, and how do you choose?
click to reveal
Four honest answers, roughly in order of how often they are right:
Substitute a default. config_dir().unwrap_or_else(|| PathBuf::from(".")).
Right when there is a sensible fallback and the user does not need to know.
Propagate. Change your own function to return Option or Result and
hand the decision to your caller. Right when you do not have enough context
to decide — which, in a library, is most of the time.
Report and exit. In main, print a real message and return a non-zero
status. Right for a CLI: the user typed something, the environment is wrong,
and they need to know which.
Panic. .expect("HOME should be set on any supported platform"). Right
only when the None case means your own assumptions are broken rather than
the user’s input being unusual — and the message should say which assumption.
What all four have in common is that you had to decide. That is the entire value of the type: it moved a decision from runtime, where it becomes a crash report, to compile time, where it becomes a design question.
Where this goes
Everything in the rest of this track is a consequence of “they are just enums”:
- Taking them apart is pattern matching, because that is how you take any enum apart.
-
map,and_then,ok_orare ordinary generic methods, and knowing which one to reach for is vocabulary, not syntax. -
?is areturnplus aFromconversion, both of which you can already read. - Your own error types are enums too, and once they implement two small traits they compose with everybody else’s.
No part of it is a special language mode. It is types, and the compiler holding you to them.