Learning Rust · Part I · Chapter 03

The Type-Driven Mindset

You have spent years checking at runtime that a value is allowed to be what it is. Rust invites you to move that worry into the types, so the bad combinations you used to guard against simply have no way to be written down.

01

The bug that compiled, and shipped

Here is a scene you know. A teammate models a connection with a struct: an is_connected flag, an address string, a last_error string. It looks reasonable, it passes review, it ships. Three weeks later a value shows up that is is_connected = true with an empty address and a non-empty last_error: connected, to nowhere, with a failure attached. Nobody meant to create that. The type simply allowed it, and one tired afternoon, some code path did.

You have spent your career defending against this. The guard clause at the top of a function (if address == "" { ... }), the assertion, the unit test for the impossible-but-somehow-real combination, the runtime check that throws when the fields disagree. All of that effort exists for one reason: the type let a bad value exist, so you had to keep catching it after the fact, everywhere, forever.

Rust offers a different deal, and it is the whole subject of this chapter. Instead of building a type that can hold nonsense and then policing it at runtime, you design the type so the nonsense cannot be written in the first place. The illegal combination does not get caught later; it never had a spelling to begin with. This is the type-driven mindset, and once it clicks, you start reaching for it before you write the first guard clause.

What this chapter is for

Not to teach you the mechanics of structs, enums, and generics; that is Part III’s job, with all the rules. This chapter is about a habit of thought: letting the shape of your types do the worrying that you used to do at runtime. You will leave able to recognize this design move and want to use it, long before you could write it fluently.

02

Make illegal states unrepresentable

Start with the smallest possible version of the problem: a traffic light. It has exactly three legal states, red, amber, or green. Now picture the design a hurried afternoon produces, three boolean flags, one per colour. Count the combinations that struct can hold: two choices for each of three flags is eight, and only three of them are legal. The other five (all off, all on, red-and-green together) are nonsense the type happily allows. Run this and watch one get built:

two-traffic-lights.rs
// A traffic light, two ways. The booleans let illegal states exist;
// the enum gives illegal states no spelling at all.

// The fragile version: three flags, eight combinations, only three legal.
struct LightFlags {
    red: bool,
    amber: bool,
    green: bool,
}

// The honest version: one type, exactly three shapes, nothing else possible.
#[derive(Debug)]
enum Light {
    Red,
    Amber,
    Green,
}

fn next(light: Light) -> Light {
    match light {
        Light::Red => Light::Green,
        Light::Green => Light::Amber,
        Light::Amber => Light::Red,
    }
}

fn main() {
    // With flags, this nonsense compiles: red AND green at once.
    let broken = LightFlags { red: true, amber: false, green: true };
    println!(
        "flags can be absurd: red={} amber={} green={}",
        broken.red, broken.amber, broken.green
    );

    // With the enum, a light is always exactly one colour. You cannot even
    // write "red and green"; there is no field to set.
    let mut light = Light::Red;
    for _ in 0..4 {
        println!("light is {light:?}");
        light = next(light);
    }
}

The LightFlags value at the top is a contradiction the compiler never questions: red and green both true at once. Then look at the enum. An enum is a type that is exactly one of a fixed set of shapes, and you met it already in I.01 and I.02, where Option was Some or None and Result was Ok or Err. Here Light is Red, Amber, or Green, and that is the entire universe of values it can hold. There is no field to set, so there is no way to write “red and green”. The five illegal states are not rejected at runtime; they were never expressible.

That is the move in one sentence: choose a type whose only inhabitants are the legal values. The booleans-bag invites illegal combinations and then asks you to police them; the enum makes the illegal combinations unrepresentable, so there is nothing left to police. The next example shows why this matters even more once each state carries its own data.

Consider a file download. It is downloading (and has a progress percentage), or it is done (and has a byte count), or it failed (and has a reason). The fragile design is a bag again: an in_progress boolean, an Option for the bytes, an Option for the error. Nothing in that bag stops you from setting in_progress = true while also stashing a byte count and an error string: done, downloading, and failed, all at once. Run it and look at the contradiction it builds:

download-states.rs
// A download has states, and each state carries exactly the data it needs.
// The bad design lets "loading and errored at the same time" exist. The enum
// makes each state carry its own data, so the impossible combinations vanish.

// The fragile shape: a bag of optional fields. Is it done? Errored? Both?
// Nothing here stops you from setting "done" and "error" together.
struct DownloadBag {
    in_progress: bool,
    bytes: Option<u64>,
    error: Option<String>,
}

// The honest shape: one enum, and the data lives inside the matching state.
// A Downloading carries progress; a Done carries bytes; a Failed carries why.
enum Download {
    Downloading { percent: u8 },
    Done { bytes: u64 },
    Failed { reason: String },
}

fn describe(d: &Download) -> String {
    match d {
        Download::Downloading { percent } => format!("downloading, {percent}% done"),
        Download::Done { bytes } => format!("done, {bytes} bytes"),
        Download::Failed { reason } => format!("failed: {reason}"),
    }
}

fn main() {
    // The bag lets you build a contradiction the type system never questions.
    let nonsense = DownloadBag {
        in_progress: true,
        bytes: Some(2048),
        error: Some("connection reset".to_string()),
    };
    println!(
        "bag says: in_progress={}, bytes={:?}, error={:?}  (which is it?)",
        nonsense.in_progress, nonsense.bytes, nonsense.error
    );

    // The enum cannot contradict itself: a value is one state, with its data.
    let states = [
        Download::Downloading { percent: 40 },
        Download::Done { bytes: 2048 },
        Download::Failed { reason: "connection reset".to_string() },
    ];
    for state in &states {
        println!("{}", describe(state));
    }
}

The DownloadBag prints a value that is asking three questions and answering all of them yes. Now read the Download enum underneath. Each variant carries exactly the data that state needs and no other: Downloading has a percent, Done has a bytes, Failed has a reason. There is no variant that holds both a byte count and an error, because there is no such state. The data and the state are welded together, so they cannot drift out of agreement.

This is the payoff a Go or TypeScript developer feels immediately. In those languages you reach for a struct of optional fields and a tag, then you write the runtime checks that keep the tag and the fields honest, and you hope every code path remembers them. In Rust the enum variant is the tag, and it physically carries its own fields, so the honesty is structural rather than remembered.

The design move, restated

When a value has a handful of distinct states, do not model it as a bundle of flags and optional fields where most combinations are illegal. Model it as an enum with one variant per legal state, each variant carrying exactly its own data. The illegal combinations then have no way to be written down, so you never have to check for them.

A Rust enum is not a C or Java enum

If “enum” makes you think of a list of named integer constants (RED = 0, AMBER = 1), reset that expectation. A Rust enum is a sum type: each variant can carry its own data of its own shape, the way Some carries a value and None carries nothing. That is what lets Download::Failed hold a reason while Download::Done holds a byte count. The mechanics of defining and matching these live in Part III; here you only need to recognize the shape.

03

Types carry meaning, not just bits

The same instinct scales down to a single value. Think of every time you have passed the wrong id to a function: the order id where the user id belonged, the customer id where the account id went. Both are integers, both fit the parameter, the compiler in your old language waved them through, and the mistake surfaced as a baffling bug in production. The arguments were the right type (an integer is an integer) but the wrong meaning.

Rust lets you give a meaning its own type. Wrap the integer in a named single-field struct, one for each kind of id, and now they are genuinely different types even though both are a u32 underneath. The compiler will refuse to let you pass one where the other is expected. Run this, then do the experiment in the comment:

ids-that-dont-mix.rs
// Both ids are just a u32 underneath, but they are different types, so the
// compiler refuses to let you pass one where the other is expected. A whole
// class of mix-up bugs simply cannot be written.

struct UserId(u32);
struct OrderId(u32);

fn ban_user(id: UserId) {
    println!("banning user {}", id.0);
}

fn cancel_order(id: OrderId) {
    println!("cancelling order {}", id.0);
}

fn main() {
    let user = UserId(7);
    let order = OrderId(7);

    // Each goes to the function that expects its own type.
    ban_user(user);
    cancel_order(order);

    // Try it: change the line above to ban_user(order) and run again. Both
    // wrap 7, but the compiler rejects it: expected UserId, found OrderId.
    // The bug that ships an order id to the user system cannot compile.
}

As written it prints cleanly, because each id goes to the function built for its type. Change ban_user(user) to ban_user(order) and run again: it does not compile. The error is blunt and exactly right, expected UserId, found OrderId. Both values wrap the number 7, but the compiler tracks what the 7 means, not just that it is a number, so the mix-up that used to be a runtime mystery is now a compile error you cannot ship past.

This trick has a name, the newtype: a one-field wrapper whose entire job is to make a distinct type out of a value that would otherwise be interchangeable with every other value of the same underlying kind. A UserId is not an OrderId; an Email is not just any String; Meters are not Feet. The wrapper costs you nothing at runtime and buys you a compiler that knows the difference.

Why bother wrapping a u32

Because the wrapper turns a category of bug into a category of compile error. In a language where every id is just an int, nothing stops you swapping two of them, and the program runs, wrong. With a UserId and an OrderId as separate types, the swap cannot compile, so the test you would have written to catch it, and the bug you would have shipped without it, both disappear.

04

Absence and failure belong in the type

You already saw this idea in I.02, where Option made absence a value and Result made failure a value. The type-driven mindset is just that idea generalized: if a fact is true about your data, write it into the typerather than leaving it to a comment or a convention. “This field might be missing” is exactly such a fact, and Option is how you say it.

Here a contact has a name and an optional nickname. The optionality is not a note in the docs or a nullwaiting to surprise you; it is right there in the field’s type, Option<String>. Run it:

optional-nickname.rs
// "Maybe absent" is a property you can write into a field, not a convention
// you hope every reader remembers. A nickname is optional, so its type says so.

struct Contact {
    name: String,
    nickname: Option<String>, // present or absent, in the type itself
}

fn greeting(c: &Contact) -> String {
    // The compiler will not let you read the nickname as a String until you
    // have said what happens when it is absent. match does exactly that.
    match &c.nickname {
        Some(nick) => format!("Hey {nick}!"),
        None => format!("Hello, {}.", c.name),
    }
}

fn main() {
    let ada = Contact {
        name: "Ada Lovelace".to_string(),
        nickname: Some("Countess".to_string()),
    };
    let grace = Contact {
        name: "Grace Hopper".to_string(),
        nickname: None,
    };

    println!("{}", greeting(&ada));
    println!("{}", greeting(&grace));
}

The field type does two jobs at once. It documents, to anyone reading the struct, that a contact may have no nickname. And it forces the code that uses the nickname to handle the absent case, because, as you learned last chapter, you cannot treat an Option<String> as a String until you have said what happens when it is None. The same is true of Resultin a return type: “this can fail” stops being a thing you remember and becomes a thing the type insists on.

Notice how naturally this composes with the enum design from the last two sections. An Option field, a Result return type, a custom enum for your states, a newtype for your ids: these are all the same instinct seen from different angles. Each one moves a fact you used to track in your head, or in a runtime check, into a place the compiler can see and enforce.

The unifying idea

Option for might-be-absent, Result for might-fail, an enum for is-one-of-these-states, a newtype for this-number-means-something-specific. Different tools, one mindset: make the type tell the truth about the data, so the compiler can keep that truth for you.

05

The compiler is a pair-programmer, not a nag

There is a moment that converts people to this style, and it happens when you change a type. Suppose a payment can be cash, a card, or a voucher, and you have a dozen functions that handle a payment by matching on it. Now the business adds bank transfers. In your old language you would add the case, then go hunting: which of those dozen functions did I forget to update? You would find out from a failing test if you were lucky, or from a customer if you were not.

In Rust, a match must be exhaustive: it has to cover every variant, and the compiler refuses to build until it does. So the instant you add a variant, every match that handled the old set stops compiling and points at the exact spot that forgot the new one. Run this first, while it still handles every case:

never-forgets-a-case.rs
// The compiler is the pair-programmer who never forgets a case. Add a new
// payment method below and every match that handled the old set lights up
// until you handle the new one too. The forgotten case becomes a build error,
// not a 2 a.m. page.

enum Payment {
    Cash,
    Card { last4: u16 },
    Voucher { code: String },
}

fn receipt(p: &Payment) -> String {
    match p {
        Payment::Cash => "paid in cash".to_string(),
        Payment::Card { last4 } => format!("paid by card ending {last4:04}"),
        Payment::Voucher { code } => format!("paid with voucher {code}"),
    }
}

fn main() {
    let payments = [
        Payment::Cash,
        Payment::Card { last4: 4242 },
        Payment::Voucher { code: "SPRING".to_string() },
    ];
    for p in &payments {
        println!("{}", receipt(p));
    }

    // Try it: add `BankTransfer` to the Payment enum and run again. The match
    // in receipt no longer covers every case, so the compiler stops you with
    // E0004 and names the variant you forgot, before the program ever runs.
}

It prints a receipt line per payment. Now do the experiment in the comment: add a BankTransfer variant to the Payment enum and run again. The receipt function no longer covers every case, so the build fails with error E0004, non-exhaustive patterns, naming BankTransfer as the variant you did not handle. In a real codebase this lights up every place that matches on Payment, turning “find all the spots I need to update” from an act of memory into a checklist the compiler hands you.

Read that as collaboration, not nagging. The compiler is not slowing you down out of pedantry; it is doing the bookkeeping you would otherwise do by hand and do imperfectly. The cases you must handle are the cases that actually exist, no more (it will not invent impossible ones, because you made them unrepresentable) and no fewer (it will not let you skip a real one). That is exactly what a good pair-programmer does: keeps track of the cases while you think about the logic.

One example here intentionally does not compile

The BankTransfer experiment above is meant to fail to compile; that failure is the lesson. Make the change and run it on purpose so you can read the real compiler error, E0004, in your own hands. Then either delete the new variant or add its arm to the match, and it builds again. A compile error in Rust is information, not a scolding: it is the bug being caught at the cheapest possible moment, before the program has ever run.

06

Parse, don't validate

One more habit completes the mindset, and it has a slogan worth remembering: parse, don’t validate. The usual approach to untrusted input is to validate it: check that the number is in range, that the string is a real email, that the percentage is between 0 and 100, and then carry on using the same loose type you checked. The trouble is that the check and the value drift apart. Three functions later, someone has the raw number again with no memory that it was ever validated, so they check it once more, or worse, do not.

Parsing turns the check inside out. Instead of validating a loose value over and over, you run the input through a single door that hands back a new type which is valid by construction. Once you hold one of those, its validity is guaranteed, because there is no other way to have built it. Run this Percent, which can only ever be 0 to 100:

valid-by-construction.rs
// Parse, don't validate: turn raw input into a type that is valid the moment
// it exists. Once you hold a Percent, it is between 0 and 100, guaranteed. No
// function downstream has to re-check, because an invalid one cannot be built.

struct Percent(u8); // the inner value is private to this idea: 0..=100 only

impl Percent {
    // The one door in. It either hands back a valid Percent or refuses.
    fn new(value: u8) -> Result<Percent, String> {
        if value <= 100 {
            Ok(Percent(value))
        } else {
            Err(format!("{value} is not a percentage"))
        }
    }

    fn value(&self) -> u8 {
        self.0
    }
}

// This function asks for a Percent, not a u8. By the time control reaches here,
// the number is already known-good. There is nothing left to validate.
fn render_bar(p: Percent) {
    let filled = p.value() / 10;
    let bar: String = "#".repeat(filled as usize);
    println!("[{bar:<10}] {}%", p.value());
}

fn main() {
    for raw in [30u8, 70, 100, 250] {
        match Percent::new(raw) {
            Ok(p) => render_bar(p),
            Err(why) => println!("rejected at the door: {why}"),
        }
    }
}

The only way to make a Percent is Percent::new, and it returns a Result: either a valid Percent or an Err explaining the refusal. So render_bar, which takes a Percent rather than a u8, never has to check the range. By the time a Percent reaches it, the value is already known-good; the 250 was turned away at the door and never became a Percent at all. The validity check happens once, at the boundary, and the type carries the result of that check everywhere it goes.

This is the same instinct as everything else in the chapter, aimed at the edge of your program where raw input arrives. Validation leaves you holding a loose value and a promise you checked it. Parsing leaves you holding a type that cannot be invalid, so the promise is kept by the compiler instead of by your discipline. The deeper machinery (how methods like new attach to a type, how to keep the inner value private so the door is the only way in) is a Part III story; the habit is what to take now.

Validate versus parse, in one line

Validating checks a value and hands back the same loose type, so the next reader must trust or re-check it. Parsing checks a value and hands back a stricter type that is valid by construction, so the next reader cannot receive a bad one. Push the parse to the boundary, and the inside of your program only ever sees values it can trust.

07

The mindset, in one breath

Look back over the six programs and you will see one idea wearing several outfits. The traffic light and the download replaced a bag of flags with an enumwhose only inhabitants are legal. The ids gave a meaning its own type so the compiler could tell them apart. The contact wrote “maybe absent” into a field with Option. The payment let the compiler track which cases you still owe. The percentage parsed raw input into a type that cannot be invalid. Every one of them moved a worry out of your head, and out of a runtime check, and into the types.

That is the type-driven mindset, and it changes how you start a problem. The old reflex is to model the data loosely and then write the guards, the assertions, and the tests that keep it honest at runtime. The Rust reflex is to ask, before any of that: can I choose types that make the bad states impossible to write? When you can, the guards and the tests and the 2 a.m. pages they were meant to prevent all evaporate together, because the compiler is now holding the invariant for you.

The shape, in one breath

Design types so that illegal states are unrepresentable: an enum per set of legal states, a newtype per meaning, Option and Result for absence and failure, and a parse at the boundary that yields a value valid by construction. Then let the compiler, your tireless pair-programmer, keep every one of those truths for you.

Two threads run ahead of here, and each has a home. Wrapping a value in a newtype or moving data into an enum raises a question this chapter stepped around: who owns that data, and what happens when it moves? That is Part II, ownership and borrowing, taught conceptually first. And the full mechanics, defining structs and enums, writing methods, making types generic, giving them shared behaviour with traits, are the rigorous heart of Part III. You have the mindset now; those parts give you the tools to wield it with your eyes closed.

The whole chapter in six lines

  1. The bug that compiles is the one your type allowed; the fix is a type that cannot hold the bad value in the first place.
  2. Make illegal states unrepresentable: an enum with one variant per legal state, each carrying exactly its own data, beats a bag of flags and optional fields.
  3. A newtype gives a meaning its own type, so a UserId and an OrderId cannot be swapped even though both wrap a u32.
  4. Put facts in the type: Option for might-be-absent, Result for might-fail, so the compiler enforces what you used to remember.
  5. Exhaustive match makes the compiler a pair-programmer: add a variant and it names every case you forgot, before the program runs.
  6. Parse, don’t validate: turn raw input into a type that is valid by construction, so the rest of the program never sees a bad value.
End-of-chapter assessmentCheck your understanding20 questions. Your first attempt is recorded on your report card.Take it →