Skip to content

State in Rust: Enums and Typestate

State changes which operations are valid as an object's lifecycle progresses. Rust enums can check that lifecycle at runtime, while state-specific types can make some operations unavailable at compile time. Neither approach proves that an external connection is working or that an operation cannot fail.

Rust State requirements and transition contract

Review enums and patterns, visibility, and moves. The independent fixtures use stable Rust edition 2024 and C++20 without external libraries.

Build an in-memory recording session, not a socket or hardware driver. Idle can open to Ready; Ready can accept integer readings and close to Closed. Opening a Ready or Closed runtime session is rejected. Sending outside Ready and closing outside Ready are rejected without changing recorded data. The typed API exposes only the operations appropriate to each type.

The state sequence is Idle → Ready → Closed. Closed retains the recorded values for inspection. Record zero and -500 in that order; neither value is an error or an empty state. Both examples produce:

1
2
3
4
5
6
before ready rejected=true
runtime closed count=2
after close rejected=true
typed closed count=2
zero retained=true
negative retained=true

Runtime misuse produces a normal failure result. Typed misuse is a separate compile-time failure and is not part of the successful program's output.

C++: runtime State and state-specific classes

Save this complete program as main.cpp. The first design uses virtual State objects; the context owns the recorded data. A transition creates its successor before replacing the old state, so no state method destroys itself while executing. The second design exposes different methods on different classes.

#include <cassert>
#include <iostream>
#include <memory>
#include <utility>
#include <vector>

struct State {
    virtual bool can_send() const = 0;
    virtual std::unique_ptr<State> open() const = 0;
    virtual std::unique_ptr<State> close() const = 0;
    virtual ~State() = default;
};

struct IdleState : State {
    bool can_send() const override { return false; }
    std::unique_ptr<State> open() const override;
    std::unique_ptr<State> close() const override { return nullptr; }
};

struct ReadyState : State {
    bool can_send() const override { return true; }
    std::unique_ptr<State> open() const override { return nullptr; }
    std::unique_ptr<State> close() const override;
};

struct ClosedState : State {
    bool can_send() const override { return false; }
    std::unique_ptr<State> open() const override { return nullptr; }
    std::unique_ptr<State> close() const override { return nullptr; }
};

std::unique_ptr<State> IdleState::open() const { return std::make_unique<ReadyState>(); }
std::unique_ptr<State> ReadyState::close() const { return std::make_unique<ClosedState>(); }

class Runtime {
    std::unique_ptr<State> state_ = std::make_unique<IdleState>();
    std::vector<int> readings_;
public:
    Runtime() = default;
    Runtime(const Runtime&) = delete;
    Runtime& operator=(const Runtime&) = delete;
    Runtime(Runtime&&) = delete;
    Runtime& operator=(Runtime&&) = delete;
    bool open() {
        auto next = state_->open();
        if (!next) return false;
        state_ = std::move(next);
        return true;
    }
    bool send(int reading) {
        if (!state_->can_send()) return false;
        readings_.push_back(reading);
        return true;
    }
    bool close() {
        auto next = state_->close();
        if (!next) return false;
        state_ = std::move(next);
        return true;
    }
    const std::vector<int>& values() const { return readings_; }
};

class Closed {
    friend class Ready;
    std::vector<int> readings_;
    explicit Closed(std::vector<int> values) : readings_(std::move(values)) {}
public:
    Closed(const Closed&) = delete;
    Closed(Closed&&) = default;
    const std::vector<int>& values() const { return readings_; }
};

class Ready {
    friend class Idle;
    std::vector<int> readings_;
    Ready() = default;
public:
    Ready(const Ready&) = delete;
    Ready(Ready&&) = default;
    void send(int reading) { readings_.push_back(reading); }
    Closed close() && { return Closed(std::move(readings_)); }
};

class Idle {
public:
    Ready open() && { return Ready{}; }
};

int main() {
    std::cout << std::boolalpha;
    Runtime runtime;
    auto before = !runtime.send(46700);
    assert(before && runtime.values().empty());
    assert(!runtime.close());
    std::cout << "before ready rejected=" << before << '\n';
    assert(runtime.open());
    assert(!runtime.open());
    assert(runtime.send(0) && runtime.send(-500));
    assert(runtime.close());
    assert((runtime.values() == std::vector<int>{0, -500}));
    std::cout << "runtime closed count=" << runtime.values().size() << '\n';
    auto after = !runtime.send(46700);
    assert(after && !runtime.close() && !runtime.open());
    assert((runtime.values() == std::vector<int>{0, -500}));
    std::cout << "after close rejected=" << after << '\n';

    Idle idle;
    auto ready = std::move(idle).open();
    ready.send(0);
    ready.send(-500);
    auto closed = std::move(ready).close();
    assert((closed.values() == std::vector<int>{0, -500}));
    std::cout << "typed closed count=" << closed.values().size() << '\n';
    std::cout << "zero retained=" << (closed.values()[0] == 0) << '\n';
    std::cout << "negative retained=" << (closed.values()[1] == -500) << '\n';
}

Run with assertions enabled, because some assertions execute the required transitions:

1
2
3
4
g++ -std=c++20 -Wall -Wextra -Wpedantic -O0 main.cpp -o state_contract
./state_contract
g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 main.cpp -o state_contract_release
./state_contract_release

The virtual State interface separates behaviour by state, but illegal operations remain callable on Runtime and must be rejected. Null here means a disallowed transition, not a missing recorded value. This context deliberately deletes copying and moving to preserve its always-present state owner; an owning pointer can be moved instead if the application needs to transfer the context. Allocation and vector growth can fail independently; false only reports the state rule in this fixture.

State-specific C++ classes can restrict method availability too: Closed has no send method. However, std::move does not invalidate the old variable as Rust does. Our C++ typed example calls each transition once; its rvalue-qualified methods are not a complete one-shot protocol. A production C++ API must define and enforce permitted operations on former state objects, rather than assume their lifetime has ended.

Rust: runtime enum and consuming typestate transitions

Create a project with cargo new state_contract --edition 2024. Replace src/main.rs with the complete program. A module boundary prevents callers from manufacturing a Ready session by writing its private fields directly.

mod session {
    #[derive(Debug, PartialEq, Eq)]
    pub enum Error {
        WrongState,
    }

    enum State {
        Idle,
        Ready(Vec<i32>),
        Closed(Vec<i32>),
    }

    pub struct Runtime {
        state: State,
    }

    impl Runtime {
        pub fn new() -> Self {
            Self { state: State::Idle }
        }

        pub fn open(&mut self) -> Result<(), Error> {
            if matches!(self.state, State::Idle) {
                self.state = State::Ready(Vec::new());
                Ok(())
            } else {
                Err(Error::WrongState)
            }
        }

        pub fn send(&mut self, reading: i32) -> Result<(), Error> {
            match &mut self.state {
                State::Ready(values) => {
                    values.push(reading);
                    Ok(())
                }
                _ => Err(Error::WrongState),
            }
        }

        pub fn close(&mut self) -> Result<(), Error> {
            if let State::Ready(values) = &mut self.state {
                let retained = std::mem::take(values);
                self.state = State::Closed(retained);
                Ok(())
            } else {
                Err(Error::WrongState)
            }
        }

        pub fn values(&self) -> &[i32] {
            match &self.state {
                State::Idle => &[],
                State::Ready(values) | State::Closed(values) => values,
            }
        }
    }

    pub struct Idle;
    pub struct Ready {
        readings: Vec<i32>,
    }
    pub struct Closed {
        readings: Vec<i32>,
    }
    pub struct Session<S> {
        state: S,
    }

    impl Session<Idle> {
        pub fn new() -> Self {
            Self { state: Idle }
        }

        pub fn open(self) -> Session<Ready> {
            Session {
                state: Ready {
                    readings: Vec::new(),
                },
            }
        }
    }

    impl Session<Ready> {
        pub fn send(&mut self, reading: i32) {
            self.state.readings.push(reading);
        }

        pub fn close(self) -> Session<Closed> {
            Session {
                state: Closed {
                    readings: self.state.readings,
                },
            }
        }
    }

    impl Session<Closed> {
        pub fn values(&self) -> &[i32] {
            &self.state.readings
        }
    }
}

fn main() {
    let mut runtime = session::Runtime::new();
    let before = runtime.send(46700).is_err();
    assert!(before && runtime.values().is_empty());
    println!("before ready rejected={before}");
    runtime.open().unwrap();
    runtime.send(0).unwrap();
    runtime.send(-500).unwrap();
    runtime.close().unwrap();
    assert_eq!(runtime.values(), [0, -500]);
    println!("runtime closed count={}", runtime.values().len());
    let after = runtime.send(46700).is_err();
    assert!(after);
    assert_eq!(runtime.values(), [0, -500]);
    println!("after close rejected={after}");

    let idle = session::Session::new();
    let mut ready = idle.open();
    ready.send(0);
    ready.send(-500);
    let closed = ready.close();
    assert_eq!(closed.values(), [0, -500]);
    println!("typed closed count={}", closed.values().len());
    println!("zero retained={}", closed.values()[0] == 0);
    println!("negative retained={}", closed.values()[1] == -500);
}

#[cfg(test)]
mod tests {
    use super::session::{Error, Runtime, Session};

    #[test]
    fn idle_rejects_send_and_close_without_data_changes() {
        let mut runtime = Runtime::new();
        assert_eq!(runtime.send(0), Err(Error::WrongState));
        assert_eq!(runtime.close(), Err(Error::WrongState));
        assert!(runtime.values().is_empty());
    }

    #[test]
    fn duplicate_open_preserves_ready_recordings() {
        let mut runtime = Runtime::new();
        runtime.open().unwrap();
        runtime.send(0).unwrap();
        assert_eq!(runtime.open(), Err(Error::WrongState));
        assert_eq!(runtime.values(), [0]);
    }

    #[test]
    fn closed_rejects_every_transition_and_preserves_data() {
        let mut runtime = Runtime::new();
        runtime.open().unwrap();
        runtime.send(0).unwrap();
        runtime.send(-500).unwrap();
        runtime.close().unwrap();
        assert_eq!(runtime.send(46700), Err(Error::WrongState));
        assert_eq!(runtime.open(), Err(Error::WrongState));
        assert_eq!(runtime.close(), Err(Error::WrongState));
        assert_eq!(runtime.values(), [0, -500]);
    }

    #[test]
    fn typed_and_runtime_paths_retain_the_same_ordered_values() {
        let mut runtime = Runtime::new();
        runtime.open().unwrap();
        let mut ready = Session::new().open();
        for reading in [0, -500, 46700] {
            runtime.send(reading).unwrap();
            ready.send(reading);
        }
        runtime.close().unwrap();
        assert_eq!(runtime.values(), ready.close().values());
    }

    #[test]
    fn an_empty_ready_session_can_close() {
        let mut runtime = Runtime::new();
        runtime.open().unwrap();
        runtime.close().unwrap();
        assert!(runtime.values().is_empty());
        assert!(Session::new().open().close().values().is_empty());
    }
}

Within the project:

1
2
3
4
5
6
cargo fmt --check
cargo check --offline
cargo test --offline
cargo test --offline --release
cargo run --offline --quiet
cargo run --offline --release --quiet

The enum keeps each runtime state with its appropriate payload. The state-specific implementations expose send only on Session. Consuming open/close transfers ownership into the next value; the caller cannot use the former session afterward. Privacy is part of that API: callers outside the module cannot forge its private state field.

The fixture's unwrap calls assert known valid transitions, not a recommendation to panic on user commands. A command dispatcher should match Result and report disallowed operations normally. Real opening, sending and closing may fail for reasons unrelated to the state type.

Changed requirements: fallible transitions and runtime storage

If opening becomes fallible and the caller must retry, consuming self and returning only an error can discard the old session. Design the error branch to return the retained old owner, for example Result, (Session\, Error)>. Test that failure preserves the data/configuration and that retry uses the returned owner. Alternatively, use a borrowed transition API whose documented failure leaves the receiver unchanged. Typestate does not choose this policy for you.

If one collection must hold sessions in any phase, distinct Session types do not fit one homogeneous vector directly. A closed enum wrapper can retain those alternatives at runtime. That is not a failure of typestate: runtime command routing and typed internal operations can be combined at a deliberate boundary.

If state implementations must be added externally, a trait-object state family may be suitable. An enum makes the closed alternatives visible to exhaustive matching, while an open family shifts some checks into the interface contract. Adding traits does not make every transition correct, and compile-time availability cannot validate the behaviour of remote devices.

Intentional failure: send is not an Idle operation

This independent Rust program isolates method availability and must fail to compile:

struct Idle;
struct Ready;
struct Session<S> {
    state: S,
}

impl Session<Ready> {
    fn send(&mut self, _reading: i32) {}
}

fn main() {
    let mut session = Session { state: Idle };
    session.send(0);
}

Use the working module's consuming open transition, then send on its returned Ready session. Do not make send available on all Session types merely to remove the error. The miniature failure program omits the production module's privacy boundary; its purpose is just to demonstrate method selection.

C++ can also reject a method absent from an Idle-specific type:

1
2
3
4
5
struct Idle {};
int main() {
    Idle session;
    session.send(0);
}

Use the complete C++ Idle::open/Ready API for the repair, remembering that the former moved-from C++ object still exists. The similarity in available method names does not make consuming transitions identical between languages.

Selection criteria and exercises

Requirement Candidate Important boundary
Commands arrive with phase unknown at runtime Enum or State interface Reject invalid operations without corrupting data
Known lifecycle sequence with phase-specific operations Typestate / state-specific classes Control construction, ownership and former-state use
Fallible transition with retry Explicit success and retained-owner error branches Define which owner survives failure
One container of mixed phases Runtime tagged wrapper Validate phase before accessing a typed operation
Externally extensible state behaviour Trait/virtual interface Document transitions and invariant responsibilities
  1. Try opening twice after a recorded zero; preserve both the Ready phase and that reading.
  2. Remove the Ready guard in runtime send and retain the invalid-operation tests. The broken contract must be detected without touching hardware.
  3. Add a simulated failed opening that returns its Idle owner, then retry successfully. Explain what differs if the failure discards self.
  4. Attempt send on Closed, reuse Ready after close, and forge Session from outside the module. Treat each compiler rejection separately from runtime user-input errors.
  5. Add a Paused phase. Identify which enum matches, State implementations and typed transitions need changing, without calling either design universally superior.

Verification and next step

On October 10, 2026, the final extracted sources passed on the authorised Raspberry Pi 4B with 64-bit user space, kernel 6.18.50+rpt-rpi-v8, Rust/Cargo 1.99.0 and GCC g++ 14.2.0. Rust check, exact-source formatting, five debug/release tests and exact outputs passed. The valid-open repair and failed-open retained-owner/retry fixture passed assertions. Idle/Closed send, reused consumed Ready and forged private construction were rejected with E0599/E0382/E0451. A deliberate illegal Idle send compiled but failed the guard test. C++20 normal output and valid typed-open/empty-close assertions passed at -O0/-O2; the repair also demonstrated that a former C++ Ready object remains callable after close. The missing Idle send was rejected. Sources/logs were retained and generated Cargo targets cleaned. This is a lifecycle fixture, not a network, shutdown or performance test.

Next: Factory and Builder, constructing valid values behind a public API.

Previous: Strategy · Course overview

Donate