Skip to content
← All articles

No null, no exceptions: Option and Result are just enums

The mental-model reset that makes the rest of Rust's error handling obvious: Option is not null, Result is not a checked exception, and neither one is a language feature.

Two sentences you will hear from people learning Rust, both wrong, and both expensive:

  • Option is Rust’s null.”
  • Result is 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:

  • map is not a keyword. It is a method someone wrote in impl<T> Option<T>. You could write it yourself and it would be four lines.
  • match is 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_or are ordinary generic methods, and knowing which one to reach for is vocabulary, not syntax.
  • ? is a return plus a From conversion, 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.