Rust vs. Go: Type-Safe State Machines Explained Through Star Wars

A long time ago in a codebase far, far away… someone called fire() too early.

This is post 10 in our Rust learning sequence. We already know ownership, borrowing, Result, Copy and Clone, lifetimes, and generics versus trait objects. Now we put those ideas together to design an API whose callers cannot perform certain operations in the wrong order.

The central question is simple: can a value’s type tell us what we are allowed to do next?

That technique is called typestate: representing an object’s state in its type. It is useful for a database transaction, a file upload, a connection handshake, or the café’s coffee machine. You fill the machine, heat it, brew, then clean it before the next batch. The next button you may press depends on its current state.

Reviewed on 19 September 2026 for Rust 1.98.1, edition 2024. Rust 1.98.1 was released on 3 September and fixes a compiler bug in vtable generation; it does not introduce typestate. The main Rust program uses only the standard library. Complete programs have a main; shorter examples are explicitly labelled as fragments. Rust release announcement.

Opening Crawl: Three Different Promises

Before we compare languages, separate three promises:

Promise A simple example What establishes it?
Memory and data-race safety Two threads cannot write through conflicting ordinary references Safe Rust’s ownership rules and sound library abstractions
A legal API sequence A charging laser has no fire() method The typestate API we deliberately design
Correct real-world behaviour The machine really cooled down; a payment really reached the bank Runtime validation, correct implementation, and the external system

Safe Rust prevents undefined behaviour such as data races, assuming the compiler and the unsafe code beneath its safe interfaces uphold their contracts. It does not prove an application’s business rules. A perfectly memory-safe program can charge the wrong customer or deadlock. Rust Reference: undefined behaviour.

Go also has static types, encapsulation, garbage collection, and synchronization primitives. It is inaccurate to describe all of Go’s safety as human discipline. The difference we will explore is narrower: Rust can consume a non-Copy value when an operation changes its state, so the caller cannot keep using the old value.

Part 1: The Death Star Incident

The protocol, before the code

Keep the Star Wars story, but think of a machine controller rather than a real weapon. Our program only prints a simulated shot.

Charging --arm--> Armed --fire--> Fired --cooldown--> Cooldown
    ^                                                   |
    +------------------- recharge ----------------------+

There are two kinds of rules:

  • Order: arm before firing; enter cooldown after firing; recharge before arming again.
  • Runtime facts: power must be 100%; the required cooling time must have elapsed.

An enum and runtime checks can enforce both. Typestate can move the order checks to compilation, while the runtime facts still need code. Neither design verifies the actual temperature of a physical device.

For a café, substitute Empty → Filled → Brewed → Cleaning → Empty. The distinction is the same: the type can record that the cleaning operation succeeded, but the operation must actually do the cleaning.

The Go implementation: Keep the check and change together

This complete program uses a runtime state machine. Each method locks the same mutex, validates its preconditions, and changes the state while still holding the lock. Save it as main.go and run go run main.go.

We use whole-number percentages to keep the example focused. Zero is an invalid state, so accidentally constructing Laser{} does not produce a working laser. The demo cooldown is 20 milliseconds so the example finishes quickly.

package main

import (
    "fmt"
    "sync"
    "time"
)

type laserState uint8

const (
    charging laserState = iota + 1
    armed
    fired
    cooling
)

// Use through a pointer. A mutex must not be copied after first use.
type Laser struct {
    mu       sync.Mutex
    state    laserState
    power    uint8
    firedAt  time.Time
    cooldown time.Duration
}

func NewLaser(cooldown time.Duration) (*Laser, error) {
    if cooldown <= 0 {
        return nil, fmt.Errorf("cooldown must be positive")
    }
    return &Laser{state: charging, cooldown: cooldown}, nil
}

func (l *Laser) SetPower(percent uint8) error {
    l.mu.Lock()
    defer l.mu.Unlock()
    if l.state != charging {
        return fmt.Errorf("power can change only while charging")
    }
    if percent > 100 {
        return fmt.Errorf("power must be between 0 and 100")
    }
    l.power = percent
    return nil
}

func (l *Laser) Arm() error {
    l.mu.Lock()
    defer l.mu.Unlock()
    if l.state != charging || l.power != 100 {
        return fmt.Errorf("arming requires charging state and 100%% power")
    }
    l.state = armed
    return nil
}

func (l *Laser) Fire() error {
    l.mu.Lock()
    defer l.mu.Unlock()
    if l.state != armed {
        return fmt.Errorf("cannot fire: laser is not armed")
    }
    l.state = fired
    l.firedAt = time.Now()
    fmt.Println("Simulated shot")
    return nil
}

func (l *Laser) Cooldown() error {
    l.mu.Lock()
    defer l.mu.Unlock()
    if l.state != fired {
        return fmt.Errorf("cooldown must follow firing")
    }
    l.state = cooling
    return nil
}

func (l *Laser) Recharge() error {
    l.mu.Lock()
    defer l.mu.Unlock()
    if l.state != cooling {
        return fmt.Errorf("recharge must follow cooldown")
    }
    if time.Since(l.firedAt) < l.cooldown {
        return fmt.Errorf("still cooling")
    }
    l.state, l.power = charging, 0
    return nil
}

func run() error {
    delay := 20 * time.Millisecond
    laser, err := NewLaser(delay)
    if err != nil {
        return err
    }
    for shot := 0; shot < 2; shot++ {
        if err := laser.SetPower(100); err != nil { return err }
        if err := laser.Arm(); err != nil { return err }
        if err := laser.Fire(); err != nil { return err }
        if err := laser.Cooldown(); err != nil { return err }
        time.Sleep(delay)
        if err := laser.Recharge(); err != nil { return err }
    }
    return nil
}

func main() {
    if err := run(); err != nil {
        fmt.Println("Stopped:", err)
    }
}

The expected output is two simulated shots. Calling Fire twice instead would return an error on the second call. Ignoring an Arm error does not bypass Fire’s check: at 50% power, Arm fails and then Fire fails too. The caller can misunderstand the outcome, but this implementation still refuses the shot.

The lock protects each operation. It does not make the entire series of method calls one transaction. If several goroutines use the same laser, their calls can interleave; the methods must remain correct under that interleaving. time.Since uses the monotonic reading retained by the time.Now() value here. Go mutex contract, Go monotonic clocks.

Four gotchas worth understanding now

1. Encapsulation has a boundary in both languages. Go’s lowercase fields are unexported outside their package; code in the package can access them. Rust’s private fields are accessible in the defining module and its descendants. Neither language stops the implementation’s own trusted code from changing a field incorrectly. Put the public API around a small, carefully reviewed implementation. Go exported identifiers, Rust visibility.

2. Error detection is not error recovery. Go permits discarded errors. Rust’s Result carries #[must_use], which normally produces a warning when discarded. let _ = operation(); can explicitly discard a Rust result. You can deny the warning, but that still cannot prove your chosen recovery is sensible. unwrap() panicking is also different from silently ignoring an error. Rust Result.

For Go, errcheck is useful tooling, not a proof of overall reliability. This is a configuration fragment for golangci-lint v2:

version: "2"
linters:
  enable:
    - errcheck
  settings:
    errcheck:
      check-blank: true

It reports explicit blank-identifier discards too. A linter configuration is worth maintaining, but claims such as “this guarantees 95% production reliability” have no basis here. Current errcheck settings.

3. Ordinary Go reflection does not erase privacy. A reflected value obtained from an unexported field is not settable. Calling SetString on it panics; it does not silently change the state. Deliberate unsafe manipulation is a different matter. reflect.Value.CanSet.

4. Numbers need a domain. If this API accepted float64/f64, checking only level < 0 || level > 100 would miss NaN: both comparisons are false. Rust’s NaN.min(100.0) returns 100.0, so clamping that way can accidentally turn invalid input into full power. For floating-point input, validate that it is finite as well as in range. Choosing an integer percentage removes NaN from this particular model, but we still check the upper limit. Go comparisons, Rust f64::min.

The Rust implementation: Let the available methods change

We will store the state as a generic field. Laser<Charging> and Laser<Armed> are different types. Only the second has fire().

The important signature is fn fire(self) -> Laser<Fired>. It takes ownership of the armed value and returns a fired value. Think of exchanging a ticket: you hand in the old ticket to receive the new one. You cannot keep using the ticket you handed in.

Two details deserve attention before the code:

  • The implementation lives in a child module. The caller in main cannot construct a Laser<Armed> by filling in private fields.
  • Failed transitions return the original laser. “Try to arm, discover insufficient power, and lose the entire device handle” would be an awkward API.

This is a complete program. Save it as main.rs and run rustc --edition=2024 main.rs, then the resulting executable.

mod death_star {
    use std::time::{Duration, Instant};

    #[derive(Debug)]
    pub struct Charging;
    #[derive(Debug)]
    pub struct Armed;
    #[derive(Debug)]
    pub struct Fired {
        at: Instant,
    }
    #[derive(Debug)]
    pub struct Cooldown {
        fired_at: Instant,
    }

    #[derive(Debug)]
    pub struct Laser<State> {
        power: u8,
        cooldown: Duration,
        state: State,
    }

    impl Laser<Charging> {
        pub fn new(cooldown: Duration) -> Result<Self, &'static str> {
            if cooldown.is_zero() {
                return Err("cooldown must be positive");
            }
            Ok(Self { power: 0, cooldown, state: Charging })
        }

        pub fn set_power(&mut self, percent: u8) -> Result<(), &'static str> {
            if percent > 100 {
                return Err("power must be between 0 and 100");
            }
            self.power = percent;
            Ok(())
        }

        pub fn arm(self) -> Result<Laser<Armed>, (Self, &'static str)> {
            if self.power != 100 {
                return Err((self, "arming requires 100% power"));
            }
            Ok(Laser {
                power: self.power,
                cooldown: self.cooldown,
                state: Armed,
            })
        }
    }

    impl Laser<Armed> {
        pub fn fire(self) -> Laser<Fired> {
            println!("Simulated shot");
            Laser {
                power: self.power,
                cooldown: self.cooldown,
                state: Fired { at: Instant::now() },
            }
        }
    }

    impl Laser<Fired> {
        pub fn cooldown(self) -> Laser<Cooldown> {
            Laser {
                power: 0,
                cooldown: self.cooldown,
                state: Cooldown { fired_at: self.state.at },
            }
        }
    }

    impl Laser<Cooldown> {
        pub fn recharge(self) -> Result<Laser<Charging>, (Self, Duration)> {
            let remaining = self.cooldown.saturating_sub(self.state.fired_at.elapsed());
            if !remaining.is_zero() {
                return Err((self, remaining));
            }
            Ok(Laser { power: 0, cooldown: self.cooldown, state: Charging })
        }
    }
}

use death_star::Laser;
use std::time::Duration;

fn main() -> Result<(), &'static str> {
    let mut laser = Laser::new(Duration::from_millis(20))?;
    laser.set_power(50)?;

    // A failed transition gives ownership back so we can correct the input.
    let mut charging = match laser.arm() {
        Ok(_) => return Err("the 50% example unexpectedly armed"),
        Err((laser, reason)) => {
            println!("Not ready: {reason}");
            laser
        }
    };
    charging.set_power(100)?;
    let armed = charging.arm().map_err(|(_, reason)| reason)?;
    let mut cooling = armed.fire().cooldown();

    let mut charging = loop {
        match cooling.recharge() {
            Ok(laser) => break laser,
            Err((laser, remaining)) => {
                cooling = laser;
                std::thread::sleep(remaining);
            }
        }
    };

    charging.set_power(100)?;
    let armed = charging.arm().map_err(|(_, reason)| reason)?;
    let _fired = armed.fire();
    Ok(())
}

The output is the insufficient-power message followed by two simulated shots. The loop retains the cooling laser and retries after waiting. If the process was paused long enough before the first check, recharge can succeed immediately; elapsed time, not a particular scheduling order, is what matters.

Instant is the standard tool for measuring elapsed time. SystemTime represents wall-clock time and can move backwards when the clock is adjusted. Instant still has documented platform caveats; it is not a physical safety certification. Instant.

Read the design through what we learned earlier

Ownership: each transition consumes the old value. The local name is not the device’s identity; moving or shadowing a name does not duplicate the resource.

Borrowing: set_power(&mut self, ...) changes data without changing the type, so it only needs an exclusive borrow. fire(self) changes the available operations, so it consumes the value.

Result: arm can return either an armed laser or the still-charging laser plus a reason. Returning Self on failure is a design choice, not automatic behaviour of Result. In the later map_err calls, we deliberately discard that handle if an unexpected error occurs and end this demo; a recoverable application would keep it.

State-specific data: Fired and Cooldown contain the timestamp they require. There is no Option<Instant> allowing a cooling state with no firing time. This is a second use of the type system: make the data match the state.

Copy and Clone: neither is implemented for Laser. A fire(self) method alone does not make a capability single-use if callers can freely duplicate that capability. When designing a resource wrapper, deciding whether cloning is meaningful is part of the protocol.

Generics: Laser<State> uses concrete types and ordinary method resolution. No dyn trait object or runtime state tag is needed for this example. It does not follow that the whole program has no runtime cost: validation, timers, printing, and any real I/O still do work.

Where does PhantomData fit?

The earlier ownership post introduced zero-sized markers and PhantomData. If the state is only a label and has no value to store, a field such as _state: PhantomData<State> can express that relationship. PhantomData<State> has size zero; it also affects automatic traits, variance, and drop checking. It is not a runtime validator. PhantomData.

In this example state: State is more useful: Charging and Armed are zero-sized markers, while the other states hold their timestamps. We therefore do not need a second phantom field. Typestate is the design pattern; PhantomData is one possible tool for implementing it.

Make the compiler’s rejection precise

These are deliberately non-compiling fragments to try inside main, using the module above.

let laser = Laser::new(Duration::from_millis(20)).unwrap();
laser.fire(); // No fire method on Laser<Charging>.
let mut laser = Laser::new(Duration::from_millis(20)).unwrap();
laser.set_power(100).unwrap();
let armed = laser.arm().unwrap();
let fired = armed.fire();
armed.fire(); // Use of moved value: armed.
let mut laser = Laser::new(Duration::from_millis(20)).unwrap();
laser.set_power(100).unwrap();
let fired = laser.arm().unwrap().fire();
fired.fire(); // No fire method on Laser<Fired>.

The second and third failures are different. Reusing the old armed variable violates ownership. Calling fire on the returned fired value fails method lookup. If you shadow every variable with let laser = ..., the latter is the error you will see.

Part 2: What the Compiler Proves—and What We Still Owe

Here is the useful comparison for these two APIs:

Situation Runtime-state Go example Typestate Rust example
Call Fire/fire while charging Call compiles; method returns an error Method is unavailable
Fire the same armed capability twice Second call checks the changed state Old value is moved; new value has no fire
Insufficient power Runtime error Runtime error
Cooling time has not elapsed Runtime error Runtime error, returning the cooling handle
Implementation forgets a necessary check Review and verification must catch it Review and verification must catch it
Caller ignores an error Allowed; tooling can report it Normally a warning; explicit discard is possible
Whole workflow must be atomic Requires application design Requires application design

The compiler proves that the caller obeys the encoded interface. It does not inspect the word Armed and infer what arming ought to mean. If we remove the power check inside arm, the Rust program still compiles.

We also changed the sharing model. The Go example allows several goroutines to hold the same pointer and serializes each method with a mutex. The Rust example transfers one owning value between phases; it does not demonstrate a shared concurrent controller. Rust supports such controllers too, but choosing their synchronization and runtime state representation is separate work. A fair comparison must name this difference.

“Only once” has a scope

The Rust API consumes one handle. It does not prove there is only one handle for a physical machine. Our public new constructor can create another simulated laser at any time.

A real device wrapper needs an acquisition rule: perhaps one process owns the device connection, or a manager lends out exclusive access. A payment service needs durable transaction IDs and deduplication when requests are retried. Rust ownership alone does not provide exactly-once external effects.

The state machine also permits a caller to drop a laser before finishing the sequence. Rust generally enforces at most one use of a moved, non-Copy value, not “every protocol must run to completion.” A destructor can help release local resources, but destructors are not guaranteed to run on process exit, abort, or deliberate leaks. Rust destructors, mem::forget.

Data races are different from stale decisions

Imagine two café tills selling the last pastry. Each locks the stock counter, sees one pastry, unlocks, and later locks again to subtract one. Every memory access can be synchronized, yet the business decision is wrong because checking and reserving were separate operations.

This is a logical race. The fix is to check and reserve under the same lock, or send a single ReserveOne command to the owner of the stock. Safe Rust prevents data races; it does not automatically make those two steps indivisible. Go’s race detector likewise does not diagnose a logically wrong sequence whose accesses are properly synchronized. Go race detector, Rust shared-state concurrency.

The laser’s Go methods already perform their own check while holding the lock. Reading a state first is never permission to assume a later Fire must succeed.

Part 3: When Typestate Helps, and When an Enum Is Clearer

Typestate is a good fit when the caller follows a small, meaningful sequence: open a transaction, write, then commit or roll back; validate an upload, then publish; configure a request, then send it.

It is less convenient when a long-running controller discovers its state from incoming events. Laser<Charging> and Laser<Armed> cannot occupy the same ordinary variable or homogeneous vector without another representation. A Rust enum can hold either, and match makes the runtime choice explicit.

These designs can cooperate. Use an enum at a boundary where state is discovered, and typed values inside a phase where the state is known. Do not force a forest of generic parameters onto a workflow just because typestate is available.

Go can also use distinct types and unexported fields to expose different methods in different phases. What it lacks is Rust’s general ownership rule that invalidates the old non-Copy binding after a consuming transition. A Go wrapper can still enforce single-use at runtime through shared state; copying a pointer is not consuming it. Go assignments, Rust moves.

Part 4: The Real Trade-Off

There is no evidence in these examples for fixed learning times, reliability percentages, or a rule that small teams should use Go and large teams should use Rust. Both require careful design, operational knowledge, and maintenance.

Rust is particularly valuable when explicit ownership, deterministic resource cleanup during ordinary execution, and compile-time restrictions on sharing or API order fit the problem. Go’s goroutines, standard library, and garbage-collected memory model may fit another team’s service well. Measure the workload and the team’s actual constraints rather than inferring uptime from the language name.

For learning Rust, the productive question is: which mistake can I make impossible at the public API boundary, and which facts must the implementation still verify?

Part 5: A Small Real-World Exercise

Return to the café. Model an order as Order<Draft>, Order<Paid>, and Order<Served>.

  1. Permit adding items only to a draft.
  2. Make payment consume the draft. On a declined payment, return the draft so the customer can try again.
  3. Permit serving only a paid order. Serving consumes the paid capability.
  4. Keep constructors and fields from letting outside code forge a paid order.
  5. Decide explicitly whether any order type may implement Clone.

Then ask the difficult question: the payment provider charged the customer, but the connection failed before your program received the response. Is it safe to return Order<Draft> and try payment again?

Not necessarily. The result is unknown, not necessarily declined. You may need a pending-payment state and a way to reconcile the provider’s result. Typestate is useful only when its states faithfully describe reality. This exercise concerns API modelling; it is not a complete payment design.

Part 6: Check Your Understanding

Try answering before reading the explanations.

  1. Does fire(self) prevent two shots if the armed resource can be cloned? No. Consuming one handle does not consume its clone. The cloning contract matters.
  2. Why does set_power use &mut self, while arm takes self? One modifies data in the same phase; the other replaces the caller’s usable type.
  3. Does Laser<Armed> prove the machine is powered? It proves the API produced that type. Trusting it requires a correct constructor/transition and control over other ways to operate the machine.
  4. Does a cooling type prove 60 seconds passed? No. It records a phase; a clock check or a completed waiting operation establishes elapsed time at runtime.
  5. Why return the old value in Err? The method took ownership. Returning it is how the caller gets the resource back for recovery.
  6. Can safe code inside death_star violate its business rules? Yes. It can construct the wrong state without unsafe; privacy protects callers from the implementation, not the implementation from its own mistakes.
  7. Can an armed value simply be dropped? Yes. Ownership does not require the caller to complete every phase.
  8. When should I prefer an enum? When state is naturally selected or stored at runtime and the runtime representation makes the program easier to understand.

You understand typestate when you can explain both its proof and its boundary. Next, Rust concurrency for Go developers applies the same ownership thinking to several workers: who owns the data, who may access it, and who is responsible for finishing the work.