Reading Rust

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.

40 minpositive transfercontrasting casesPOE
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

PropertyTypeScript discriminated unionRust enum
The tagA field you add by convention, of a type you chooseBuilt in, generated, and impossible to forge
ExhaustivenessOnly if strict plus a never check, or noImplicitReturnsAlways enforced. A missing arm is a compile error
Can you build an invalid value?Yes — as, JSON parsing, any anyNo. The variants are the only inhabitants
Runtime costAn extra string property per objectUsually one small integer — often zero
Do the types survive to runtime?No, erasedYes — 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

Predict firstcommit in writing, then look
fn main() {
    println!("{} {} {} {}",
        size_of::<bool>(),
        size_of::<Option<bool>>(),
        size_of::<Option<&u8>>(),
        size_of::<Option<Option<&u8>>>(),
    );
}

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

ScrutineePatternType of sWas anything moved?
maybe (an Option<String>)Some(s)StringYesmaybe is consumed
&maybeSome(s)&StringNo
&mut maybeSome(s)&mut StringNo

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.

Beaconsee this → think this, without decoding it

#[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.

Explain it to yourselfwrite it — thinking it does not count

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.
  • Option and Result are ordinary library enums — nine lines each.
  • Niche optimisation makes Option<&T> free; Option<u8> is not, because u8 has 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.

Next: 3.2 — Traits are constraints, not types.

In the wildgo read the real thing now, while it is fresh
  • std::option::Option — the definitionClick "source" on the enum. It is nine lines. Everything else on that enormous page is ordinary methods in an impl block — nothing about Option is built into the language.
  • std::net::IpAddrA two-variant enum where each variant holds a different concrete type. This is the shape you cannot express with a Java enum, and it is why Rust enums feel like a different feature despite the shared keyword.
Scheduled for recall4 items added to your review queue

These are not shown to you now on purpose. They will surface in Review tomorrow, mixed in among items from other parts of the course — not grouped by topic. Sorting out which idea applies is the part that transfers.