Part 3 · The compile-time machine II — types & traits
Enums are TypeScript discriminated unions, done right
Your strongest advantage in this entire course. You already think in sum types — Rust just enforces what TypeScript asks you to remember.
What this lesson is trying to break (3)
- A Rust enum is like a Java enum or a TypeScript string-literal union.
- `Option` and `Result` are special language features.
- Wrapping values in enums costs memory.
Good news, and it is worth saying explicitly: you already know this one.
If you write TypeScript seriously, you write discriminated unions. That is the same idea Rust calls an enum, and it is one of the harder concepts for people arriving from Java or Go. You get it free.
Side by side
Here is a type you have written many times:
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rect'; w: number; h: number };
function area(s: Shape): number {
switch (s.kind) {
case 'circle': return Math.PI * s.radius ** 2;
case 'rect': return s.w * s.h;
}
}
And here it is in Rust:
enum Shape {
Circle { radius: f64 },
Rect { w: f64, h: f64 },
}
fn area(s: &Shape) -> f64 {
match s {
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
Shape::Rect { w, h } => w * h,
}
}
Structurally identical. kind becomes the variant name, the switch becomes a
match, narrowing becomes pattern matching.
Now the differences, which are all in Rust’s favour and all about enforcement rather than expressiveness.
Varies: only the language — the modelled type is the same in both columns
| Property | TypeScript discriminated union | Rust enum |
|---|---|---|
| The tag | A field you add by convention, of a type you choose | Built in, generated, and impossible to forge |
| Exhaustiveness | Only if strict plus a never check, or noImplicitReturns | Always enforced. A missing arm is a compile error |
| Can you build an invalid value? | Yes — as, JSON parsing, any any | No. The variants are the only inhabitants |
| Runtime cost | An extra string property per object | Usually one small integer — often zero |
| Do the types survive to runtime? | No, erased | Yes — layout and dispatch depend on them |
The concept transfers directly. What changes is that TypeScript asks you to maintain the discipline and Rust simply enforces it.
That third row is the one to dwell on. In TypeScript, a discriminated union
describes what you intend; a bad as cast or an unvalidated API response can
still hand you { kind: 'circle' } with no radius. In Rust the variants are the
complete list of possible values, checked at every construction site. The type is
a guarantee rather than a description.
Option and Result are not special
This surprises people, and it is a genuinely clarifying fact.
enum Option<T> {
None,
Some(T),
}
enum Result<T, E> {
Ok(T),
Err(E),
}
That is the whole definition of each. They are ordinary library enums, written
in Rust, with no compiler magic. Everything else — unwrap, map, and_then,
ok_or — is normal methods in an impl block that you could have written.
The only concession the language makes is syntax: ? desugars into a match on
Result or Option, and Option is in the prelude so you never import it.
The implication that matters for reading code: when you meet a new enum in a
crate, it works exactly like Option. There is no separate category of
“special” types to learn. std::net::IpAddr, serde_json::Value, and
std::cmp::Ordering are all the same construct.
Niche optimisation
fn main() {
println!("{} {} {} {}",
size_of::<bool>(),
size_of::<Option<bool>>(),
size_of::<Option<&u8>>(),
size_of::<Option<Option<&u8>>>(),
);
}1 1 8 8bool uses two of its 256 possible byte values. The other 254 are niches —
invalid bit patterns going spare. The compiler encodes None as one of them, so
Option<bool> needs no extra tag byte.
&u8 cannot be null, so Option<&u8> uses null for None and stays one word.
The fourth is the fun one. Option<Option<&u8>> is still 8 bytes: there are
plenty of invalid pointer values left, so None and Some(None) get different
ones. The nesting is free.
This is the concrete content of “zero-cost abstraction”, and it is a much better
example than the ones usually offered. In Java, Optional<T> is a heap
allocation wrapping a reference. In Rust, Option<&T> is a nullable pointer
— identical machine code to the C version — with the compiler forcing you to
check before you use it. You get the safety and pay nothing.
Worth knowing where it stops: Option<u8> is 2 bytes, because u8 uses all 256
patterns and there is no niche to steal.
Match ergonomics — the reading trap
Now the one thing about enums that is genuinely confusing to read, and which almost nothing explains.
let maybe = Some(String::from("hi"));
match &maybe {
Some(s) => println!("{s}"),
None => {}
}
What is the type of s? The pattern says Some(s), which looks like it should
bind the String by value — and moving out of a & is E0507, so this should not
compile.
It compiles, and s is a &String.
The mechanism is match ergonomics (formally, default binding modes). When you match a reference against a non-reference pattern, the compiler dereferences the scrutinee and flips the default binding mode to “by reference”. Every binding inside then becomes a reference automatically.
This is a large ergonomic win — before it landed you had to write
Some(ref s) or &Some(ref s) and it was miserable — but it has a cost for
readers: the same pattern text means different things depending on the
scrutinee.
Varies: only the scrutinee — the pattern text is identical in all three rows
| Scrutinee | Pattern | Type of s | Was anything moved? |
|---|---|---|---|
maybe (an Option<String>) | Some(s) | String | Yes — maybe is consumed |
&maybe | Some(s) | &String | No |
&mut maybe | Some(s) | &mut String | No |
When you read a match, look at the scrutinee before you interpret the bindings. This is a common source of “why is this a reference?” confusion in unfamiliar code.
So the reading rule is: check what you are matching on first. The patterns alone do not tell you.
The idiom: make invalid states unrepresentable
This phrase comes up constantly in Rust discussions and it is worth being able to use precisely. It means: choose your types so combinations that should never happen have no representation.
The anti-pattern, which you have written in TypeScript:
struct Request {
is_loaded: bool,
data: Option<Response>,
error: Option<String>,
}
How many of the eight combinations are valid? Three, maybe. What does
is_loaded: true, data: None, error: None mean? Nobody knows, and eventually
somebody constructs it.
The enum version:
enum Request {
Pending,
Loaded(Response),
Failed(String),
}
Three states, all meaningful, no impossible combinations, and the compiler makes you handle each one at every use site. The bug class is not caught — it is deleted.
You can do this in TypeScript too, and good TypeScript does. The difference is that Rust makes it the path of least resistance rather than a discipline you maintain.
#[non_exhaustive]
“I may add variants later; do not assume this list is complete.”
On a public enum, this forces downstream match blocks in other crates to
include a _ => arm, so adding a variant is not a breaking change.
When you see it, read: this is a library type the author expects to grow. When you see a crate’s enum without it, adding a variant is semver-major — which is a real API-design conversation you may have to lead one day.
Three small forms worth recognising
if let Some(x) = maybe { /* ... */ } // one arm, ignore the rest
let Some(x) = maybe else { return; }; // "let-else": bind or diverge
if matches!(shape, Shape::Circle { .. }) { } // boolean test, no binding
let ... else is the one to notice. It binds in the enclosing scope and
requires the else block to diverge (return, break, panic!, continue).
It replaced a very common awkward match and it reads beautifully once you know
it — a guard clause that also binds.
Model this as a Rust type, using enums to remove impossible states:
A deployment is either queued (with a position), running (with a start time and a percentage), finished successfully (with a duration), or failed (with a duration and an error message).
Then answer: how many fields would the naive struct version have, and how many of its possible combinations would be meaningless?
What you should now be able to say
- A Rust enum is a TypeScript discriminated union, with the tag and the exhaustiveness enforced rather than maintained.
OptionandResultare ordinary library enums — nine lines each.- Niche optimisation makes
Option<&T>free;Option<u8>is not, becauseu8has no spare patterns. - Match ergonomics mean the same pattern binds differently depending on the scrutinee — check the scrutinee first.
- “Make invalid states unrepresentable” means replacing flag-plus-optional structs with enums.
#[non_exhaustive]signals a library type that expects to grow.