Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Making Illegal States Unrepresentable

Here is a claim worth taking seriously: every runtime check in your program is a fact you failed to write into a type.

That is too strong as an absolute — some facts arrive from the outside world at runtime and cannot be known earlier — but as a lens it is remarkably productive. Point it at a codebase and you find the same thing everywhere. A method that begins by checking whether the object has been initialized. A comment saying that endDate is only meaningful when status is CLOSED. A test asserting that you cannot call send() twice. A null check written by someone who had been burned, sitting next to an identical one written by someone else.

Each of those is a fact about which states are legal, enforced by vigilance. Chapter 5 introduced the two tools that let you enforce them by construction instead: an enum that only permits the shapes you want, and a private field with a fallible constructor. This chapter is about spending them.

Count the states

Start with a type that looks entirely reasonable:

#![allow(unused)]
fn main() {
struct Socket {
    handle: u32,
}

struct Connection {
    address: String,
    socket: Option<Socket>,
    connected: bool,
    last_error: Option<String>,
}
}

Four fields, and every codebase in every language contains something like it. Now count what it can represent. Ignoring the address, there are two choices for socket, two for connected and two for last_error: eight combinations.

How many are legal? Connected with a socket and no error, yes. Disconnected with no socket and an error, yes. Disconnected with no socket and no error — the state before the first attempt — yes. Three legal states out of eight.

The other five are the bugs. Connected with no socket is a null dereference waiting to happen, and it is exactly what you get if a reconnection path clears the socket and forgets the flag. Connected with an error is a state where two pieces of code disagree about what happened. Every method on this type has to decide what to do about combinations that are not supposed to occur, and the usual decision is not to think about them.

The enum version cannot express them:

struct Socket {
    handle: u32,
}

enum Connection {
    Untried { address: String },
    Failed { address: String, error: String },
    Live { address: String, socket: Socket },
}

impl Connection {
    fn address(&self) -> &str {
        match self {
            Connection::Untried { address } => address,
            Connection::Failed { address, .. } => address,
            Connection::Live { address, .. } => address,
        }
    }

    fn handle(&self) -> Option<u32> {
        match self {
            Connection::Live { socket, .. } => Some(socket.handle),
            _ => None,
        }
    }
}

fn main() {
    let connection = Connection::Live {
        address: String::from("db.internal:5432"),
        socket: Socket { handle: 7 },
    };
    println!("{} {:?}", connection.address(), connection.handle());
}

Three states, three variants, and the five broken combinations no longer have a spelling. A socket exists only inside the variant where a socket is meaningful, which means no code anywhere can reach for one that is not there. The Option that remains, on handle, is doing real work: it reports that a connection which is not live has no handle, and the caller has to decide what that means.

This is the core move, and it generalizes: when two fields are only meaningful in particular combinations, they belong in variants of an enum rather than alongside each other in a struct. The tell is a boolean that governs whether another field means anything, or an Option whose documentation says when it is Some.

Coming from an object-oriented language

Encapsulation was aiming at this. Making fields private and forcing access through methods is an attempt to stop objects reaching states the class does not want, and it half works: the methods can enforce rules, but the object still has all those fields, and any method that sets one can leave the combination invalid until another one runs. The enum removes the intermediate state rather than defending it.

Parse, do not validate

The second tool addresses a different failure, which is a fact that gets checked repeatedly because nothing remembers that it was checked.

#![allow(unused)]
fn main() {
fn send_receipt(email: &str, body: &str) -> Result<(), String> {
    if !email.contains('@') {
        return Err(String::from("invalid email"));
    }
    println!("sending to {email}: {body}");
    Ok(())
}
}

The check is fine. The problem is that email is a String, so the next function that receives it knows nothing, and it checks again. And the one after that forgets to. The validation is a property of a moment rather than of the value, so it evaporates the instant the value is passed along.

Move the check into a type and it stops evaporating:

pub struct Email(String);

impl Email {
    pub fn parse(raw: &str) -> Result<Email, String> {
        let trimmed = raw.trim();
        match trimmed.split_once('@') {
            Some((local, domain)) if !local.is_empty() && domain.contains('.') => {
                Ok(Email(trimmed.to_lowercase()))
            }
            _ => Err(format!("{raw:?} is not an email address")),
        }
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

fn send_receipt(email: &Email, body: &str) {
    println!("sending to {}: {body}", email.as_str());
}

fn main() {
    match Email::parse("  David.Anderson@Excelano.com ") {
        Ok(email) => send_receipt(&email, "thanks"),
        Err(message) => println!("{message}"),
    }
}

send_receipt no longer validates and no longer returns a Result, because it can no longer fail. The only way to obtain an Email is parse, the field is private so nobody can construct one around an arbitrary string, and every function downstream can treat the value as trustworthy without a comment saying so.

The name for this discipline is parse, don’t validate. A validating function takes a value and returns a verdict, throwing the knowledge away. A parsing function takes a value and returns a different, narrower type that carries the knowledge in its identity. Same check, same cost, and the result is remembered by the type system instead of by the programmer.

Notice the second thing the parser did: it trimmed and lowercased. A parse function is the natural place to normalize, because it is the one place every value passes through. Two Email values that mean the same address now compare equal, which is a bug class quietly closed on the way past.

The standard library does this to itself. NonZeroU32 is an integer that cannot be zero, so a function taking one does not check and a division by it cannot trap. String is a Vec<u8> that has been parsed as UTF-8, which is why &str methods can assume valid encoding rather than checking on every call.

Making illegal transitions unrepresentable

The third variety is about sequence rather than shape. Some operations are only legal in a particular order, and the usual enforcement is a status field plus some if statements:

#![allow(unused)]
fn main() {
struct Article {
    body: String,
    status: String,
    reviewer: Option<String>,
}

impl Article {
    fn publish(&mut self) -> Result<(), String> {
        if self.status != "approved" {
            return Err(String::from("not approved"));
        }
        self.status = String::from("published");
        Ok(())
    }
}
}

Everything about that is checkable only at runtime, the status strings are uncontrolled, and nothing stops a second publish.

Chapter 3 mentioned that a method taking self by value consumes the receiver. That is the tool here: make each stage its own type, and make the transitions consume the previous stage so it cannot be used again.

pub struct Draft {
    body: String,
}

pub struct InReview {
    body: String,
    reviewer: String,
}

pub struct Published {
    body: String,
}

impl Draft {
    pub fn new(body: String) -> Draft {
        Draft { body }
    }

    pub fn submit(self, reviewer: String) -> InReview {
        InReview { body: self.body, reviewer }
    }
}

impl InReview {
    pub fn approve(self) -> Published {
        println!("approved by {}", self.reviewer);
        Published { body: self.body }
    }

    pub fn reject(self) -> Draft {
        Draft { body: self.body }
    }
}

impl Published {
    pub fn body(&self) -> &str {
        &self.body
    }
}

fn main() {
    let article = Draft::new(String::from("how to think in ownership"));
    let published = article.submit(String::from("editor")).approve();
    println!("{}", published.body());
}

There is no status field, no check, and no Result on any transition, because none of them can fail. publish is not a method on Draft at all, so calling it too early is not a runtime error — it is a compile error saying no such method exists. Publishing twice is impossible because approve consumed the InReview and there is nothing left to call it on again. The state machine is enforced by which methods exist on which type, which means the compiler checks the diagram.

The pattern is called typestate, and there is a more elaborate version using a generic parameter and PhantomData that keeps the fields in one struct. The version above, with a struct per state, is easier to read and is the one to reach for first.

Note

Watch for this in the standard library and in crates you use, now that you know the shape. A Vec iterator consuming the vector, a builder’s build taking self, a lock guard that releases on drop, a File opened for reading having no write method: all the same idea. Once you see it you will notice how much of Rust’s library surface is state machines encoded in which methods exist.

What this costs, and when not to bother

The technique has a real price and the price is paid in conversions. A program with Email, CustomerId, Sku and Percentage types has more moving parts than one that uses String, String, String and f64. Serialization needs attention. Reading one out to concatenate with something means calling as_str(). A newtype with no methods is annoying to use, so each one accumulates a small surface of its own.

Three guides for where to spend it.

Spend it at boundaries. Data arriving from a network, a file, a database or a user is untrusted and heterogeneous, and the boundary is where parsing belongs. Inside the program, past the boundary, the narrow types are what make the rest of the code simple. A function deep in the core that takes Email and Sku cannot be called with the arguments in the wrong order, and that mistake is otherwise invisible to every tool you own.

Spend it where the fact has many readers. A rule checked in one function and never again is fine as a check. A rule that six modules depend on wants to be a type, because six modules is six chances to forget.

Do not spend it on local intermediate values. A number computed on one line and consumed on the next does not need a newtype. The technique is about facts that travel; a value that never leaves the function has nowhere to travel to.

There is also a genuine limit. Some invariants cannot be expressed in types without more machinery than they are worth — that a discount never exceeds the subtotal, that two collections stay the same length, that a timestamp is in the past. For those, a private field and a method that upholds the rule is the right answer, and it is still better than a public field and a convention.

Conclusion

A type can represent states that a program considers illegal, and every one of those states is a bug waiting for a code path to produce it. Three moves shrink the gap. Fields that are only meaningful together belong in enum variants, which deletes the invalid combinations outright. A validating check belongs in a constructor that returns a narrower type, so the knowledge survives being passed along. A sequence of allowed operations belongs in a series of types whose transitions consume the previous one, so the order is checked at build time.

All three replace vigilance with structure, and the test for whether they are paying off is simple: count the checks that disappeared, and count the states that no longer have a spelling.

The next chapter goes back to the tool that does most of this work — the enum, and the match that makes it safe to extend.

Questions to think through

Take a class you know with a status or state field. Count the fields whose meaning depends on that status, and write down which combinations of status and field are actually legal. How many representable states are there, and how many of those are legal?

The Email::parse function normalizes as well as validates. What would break in a system that validated email addresses everywhere but normalized them nowhere?

The typestate example has no Result on any transition. Find an operation in your own code that returns an error solely because it might be called at the wrong time, and sketch what the types would have to be for that error to become impossible.