RustArena

Step 3 of 7

Make invalid states unrepresentable

Look at this struct, the kind found in many codebases:

struct Connection {
    connected: bool,
    connecting: bool,
    session_id: Option<String>,
    retries: u32,
    error: Option<String>,
}

What does connected: true, connecting: true mean? Or connected: false with a session_id? Those combinations are possible, so every function has to check for them, and someone eventually forgets. The idiomatic Rust fix: an enum where each state carries exactly its data, so nonsense combinations can't even be written:

enum Connection {
    Disconnected,
    Connecting { attempt: u32 },
    Connected { session_id: String },
    Failed { reason: String },
}

There's no session id unless you're connected, and match makes every function handle every state.

Enum state machines

Add transitions and you have a state machine. Matching on (state, event) pairs lists every allowed move in one place, and taking self by value means the old state is consumed: nobody can keep using a stale one.

#[derive(Debug)]
enum Order {
    Cart { items: u32 },
    Paid { items: u32, payment_id: String },
    Shipped { tracking: String },
    Cancelled,
}

#[derive(Debug)]
enum Event {
    AddItem,
    Pay(String),
    Ship(String),
    Cancel,
}

impl Order {
    fn apply(self, event: Event) -> Result<Order, (Order, String)> {
        match (self, event) {
            (Order::Cart { items }, Event::AddItem) => Ok(Order::Cart { items: items + 1 }),
            (Order::Cart { items }, Event::Pay(id)) if items > 0 => Ok(Order::Paid { items, payment_id: id }),
            (Order::Paid { .. }, Event::Ship(tracking)) => Ok(Order::Shipped { tracking }),
            (Order::Cart { .. } | Order::Paid { .. }, Event::Cancel) => Ok(Order::Cancelled),
            (state, event) => {
                let msg = format!("cannot apply {event:?} to {state:?}");
                Err((state, msg))
            }
        }
    }

    fn describe(&self) -> String {
        match self {
            Order::Cart { items } => format!("cart with {items} items"),
            Order::Paid { items, payment_id } => format!("{items} items paid ({payment_id})"),
            Order::Shipped { tracking } => format!("shipped, tracking {tracking}"),
            Order::Cancelled => String::from("cancelled"),
        }
    }
}

fn main() {
    let mut order = Order::Cart { items: 0 };
    let events = [
        Event::Pay(String::from("pay_1")),
        Event::AddItem,
        Event::AddItem,
        Event::Pay(String::from("pay_2")),
        Event::Ship(String::from("TRK9")),
        Event::Cancel,
    ];
    for event in events {
        order = match order.apply(event) {
            Ok(next) => next,
            Err((same, msg)) => {
                println!("rejected: {msg}");
                same
            }
        };
        println!("now: {}", order.describe());
    }
}
rejected: cannot apply Pay("pay_1") to Cart { items: 0 }
now: cart with 0 items
now: cart with 1 items
now: cart with 2 items
now: 2 items paid (pay_2)
now: shipped, tracking TRK9
rejected: cannot apply Cancel to Shipped { tracking: "TRK9" }
now: shipped, tracking TRK9

Piece by piece:

Why does the error carry the state back, as Err((state, msg))? Because apply took ownership of it. If the error held only a message, a rejected event would leave the caller with nothing, and the borrow checker says so:

enum Light {
    Off,
    On,
}

impl Light {
    fn press(self, allowed: bool) -> Result<Light, String> {
        if !allowed {
            return Err(String::from("locked"));
        }
        Ok(match self {
            Light::Off => Light::On,
            Light::On => Light::Off,
        })
    }
}

fn main() {
    let mut light = Light::Off;
    for allowed in [true, false, true] {
        match light.press(allowed) {
            Ok(next) => light = next,
            Err(e) => println!("{e}"),
        }
    }
    if let Light::On = light {
        println!("on");
    }
}
error[E0382]: use of moved value: `light`

In the Err arm, light has been moved into press and nothing was put back. Returning the unchanged state inside the error (or taking &mut self and changing it in place) solves it.

Typestate: wrong order is a compile error

When the sequence of steps is fixed in your code (not decided by runtime data), you can go further and give each state its own type (the typestate pattern from the advanced traits module). Methods exist only on the states where they make sense:

struct Draft {
    text: String,
}

struct Published {
    text: String,
}

impl Draft {
    fn publish(self) -> Published {
        Published { text: self.text }
    }
}

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

fn main() {
    let post = Draft { text: String::from("Hello, crabs") }.publish();
    println!("{}", post.text());
    let again = post.publish();
}
error[E0599]: no method named `publish` found for struct `Published` in the current scope

Which one? Use an enum when the state comes from data at run time (an order loaded from the database can be in any state). Use typestate for protocols and builders written in code (a connection must be opened before sending, a request needs a URL before send).

Your task

Write a payment state machine. Payment::next(self, event) returns the new state or an error string. Allowed transitions:

Anything else is Err(format!("cannot {event:?} a {} payment", state.name())), where name() returns pending, authorized, captured, refunded or failed.

Challenge 2

Replace the flag soup. Write Connection::on(self, event: NetEvent) -> Connection for this enum: Disconnected + Dial becomes Connecting { attempt: 1 }; Connecting + Timeout retries with attempt + 1, but after attempt 3 gives up and becomes Disconnected; Connecting + Accepted(session) becomes Connected { session }; Connected + Hangup becomes Disconnected. Any other event leaves the state unchanged. Also write status(&self) -> String: offline, dialing (attempt 2), online as s-42.

Challenge 3

Typestate: a request can only be sent once it has a URL. RequestBuilder::new() has no URL; .url(..) returns a ReadyRequest (a different type) holding it, and only ReadyRequest has .header(..) (chainable) and .send(), which returns a description like GET https://arena.dev [accept: json] (headers as name: value joined by , , and no brackets when there are no headers). Calling send on a RequestBuilder simply doesn't compile.

Previous: Newtypes: give plain values a meaning Next: Conversions and flexible parameters: From, Into, AsRef