Crabrix blog

Rust enums and state machines: make invalid states explicit

Model Rust workflows with enums, data-bearing variants and explicit transitions. Handle invalid commands, preserve state on failure, and test every edge.

When two flags stop explaining the workflow

A document starts as a draft, becomes ready after validation, and can then be sent. Editing a ready document makes it a draft again because its previous validation no longer describes the content. A sent document is final in this deliberately small workflow. These rules are simple enough to say aloud, yet a pair of booleans can make them surprisingly difficult to preserve in code.

Suppose the representation uses valid and sent. Two flags allow four combinations. Our workflow intends three: an unvalidated draft, a validated ready document and a sent document that passed validation. The fourth combination, sent but not valid, has no meaning in this design. Every caller that changes either flag must remember that restriction, and every reader must reconstruct which combinations are supposed to exist.

A Rust enum gives the intended states names. It does not automatically solve persistence, external delivery or every transition rule, but it gives those decisions one explicit representation. We will build a small state machine, distinguish runtime validation from compile-time restrictions, and examine what happens when an operation fails. The examples use the standard library only.

Three miniature document stations connect draft preparation, validation and archiving, operated by a crab at a railway switch.
Name the intended states and the legal paths between them before choosing the implementation.

Draw the rules before choosing methods

Our states are Draft, Ready and Sent. The legal transitions are draft to ready on validation, ready to sent on sending, and ready to draft on editing. All other combinations of these commands and states are rejected in the minimal model. In a real editor, editing a draft would usually be allowed without changing its state. We leave that operation outside this example so every transition changes the state and the table stays easy to audit.

The state diagram is a contract for this particular product, not a universal law about documents. Another product might allow a sent document to be recalled, record several delivery attempts, or require a review state. Write those rules explicitly before adding variants. A diagram that merely lists the states but omits what each command does cannot tell you whether an implementation is correct.

The infographic compares the three intended states with the four flag combinations. The “invalid” row is invalid because of the rule we chose: this workflow never sends unvalidated content. That qualification matters. Booleans are not inherently wrong; they become a poor fit when independent toggles pretend to represent dependent domain states.

Draft validates to Ready, Ready sends to Sent or returns to Draft after editing; a table highlights the unintended sent-but-invalid flag combination.
The enum represents three intended states. Transition validation still decides which command is legal in the current state.

A small runtime state machine you can run

The following program stores the current state and exposes one transition operation. It matches the current state together with the requested action. Only three pairs produce a next state. Every rejected pair returns an error before assigning to the state variable, so the previous state is preserved. That ordering is an important part of the contract; returning an error after partially changing the state would be a different and much more difficult API.

The assertions walk through a successful route and an invalid command. Sending a draft fails, validation makes it ready, editing invalidates that readiness, and validation followed by sending reaches the final state. The final command also fails without changing the sent state. These are ordinary runtime checks. Calling apply with the wrong action still compiles because all actions share one public type.

Use this version when actions arrive as runtime data, such as a button identifier, imported event or network command. An enum-based machine can validate that data clearly. It should not be described as proving every transition correct at compile time; its explicit Result exists precisely because some requests must be rejected while the program is running.

#[derive(Debug, Clone, Copy, PartialEq)]
enum State { Draft, Ready, Sent }
#[derive(Clone, Copy)]
enum Action { Validate, Edit, Send }
fn apply(state: &mut State, action: Action) -> Result<(), &'static str> {
    let next = match (*state, action) {
        (State::Draft, Action::Validate) => State::Ready,
        (State::Ready, Action::Edit) => State::Draft,
        (State::Ready, Action::Send) => State::Sent,
        _ => return Err("action is not allowed in this state"),
    };
    *state = next;
    Ok(())
}
fn main() {
    let mut state = State::Draft;
    assert!(apply(&mut state, Action::Send).is_err());
    assert_eq!(state, State::Draft);
    apply(&mut state, Action::Validate).unwrap();
    apply(&mut state, Action::Edit).unwrap();
    assert_eq!(state, State::Draft);
    apply(&mut state, Action::Validate).unwrap();
    apply(&mut state, Action::Send).unwrap();
    assert_eq!(state, State::Sent);
    assert!(apply(&mut state, Action::Validate).is_err());
    assert_eq!(state, State::Sent);
    println!("Workflow checks passed: {state:?}");
}

Let variants carry the data they require

States often differ in more than their name. A draft may contain editable text. A ready document may include text together with a validation summary. A sent document may include an identifier or receipt. Putting every field into one struct with several Option values allows combinations such as “sent with no receipt” or “draft with an obsolete approval.” Data-bearing enum variants can express which fields belong together.

For example, a Sent { receipt: String } variant always carries a string when it exists. That removes the separate question of whether a receipt field is present in that variant. It does not prove the string is nonempty, authentic or issued by a real service. If those properties matter, validate them through a constructor or a dedicated type. Structural guarantees are valuable, but they must not be exaggerated into claims about external reality.

Keep the representation close to the domain. If every variant carries the same large field and only a small status differs, a shared struct with an enum status may be simpler. If fields exist only in specific states, putting them in those variants can eliminate repetitive optional-field checks and make invalid combinations harder to construct.

Sources: The Rust Book: defining enums

Use exhaustive matches to review new states

When matching an enum, Rust requires the match to cover the possible variants. This gives a useful maintenance signal when a new state is added: code that explicitly handles every old state needs another decision. A display label, transition rule and persistence conversion may all need to account for a new AwaitingReview state. The compiler can point to missing structural cases, even though it cannot decide what the correct product behavior should be.

A wildcard arm suppresses that signal for the cases it covers. Sometimes that is appropriate. Our transition function intentionally rejects every pair outside the three allowed transitions. For a user-facing label function, however, a wildcard returning “Other” can hide the fact that the new state needs a meaningful label. Choose explicit arms where a future variant should force a review.

Exhaustiveness is a tool for remembering decisions, not a replacement for making them. A match can be exhaustive and still send a document from the wrong state. Keep assertions about the transition table alongside the structural representation. The compiler checks coverage; your tests and review check the intended rule.

Sources: The Rust Book: match control flow

A transition error should leave a usable object

Failure behavior deserves as much design as the happy path. In the mutable-reference example, an invalid transition leaves the state untouched. If validation inspects content and returns an error, the user should normally retain the draft so they can correct it. An API that consumes the document and returns only an error can accidentally make recovery awkward because the caller no longer owns the original content.

There are several ways to preserve recoverability. Borrow during validation, return the original value together with the error, or make the operation produce a candidate before replacing the stored value. The right choice depends on ownership and the cost of rebuilding state. Do not add a clone just to conceal an unclear failure contract. First decide which data the caller must still have after rejection.

Tests should verify preservation explicitly. Start with identifiable content, request an invalid transition, and compare the state and relevant fields afterward. Seeing an error value is insufficient evidence if the operation already discarded edits, reset a selection or erased the receipt needed to explain a previous send.

Keep external effects outside the simple transition rule

Marking an object sent and actually delivering it to another system are different events. A pure transition function can be deterministic, while a network request can time out after the remote side accepted it. If the application simply changes Ready to Sent before attempting delivery, a failure may leave it claiming success. If it changes state afterward, an ambiguous timeout may still require a separate reconciliation policy.

A production workflow might introduce Sending, Failed or DeliveryUnknown, along with an operation identifier that makes retries safe for the particular service. Those details depend on the external system’s guarantees. Do not claim that an enum alone provides exactly-once delivery, a transaction or crash recovery. It provides names in which those possibilities can be represented and handled deliberately.

Keep the small example small while recognizing its boundary: our Send action represents a confirmed domain transition and performs no I/O. When adding real delivery, design and test the effect boundary separately. That prevents a clean educational state diagram from being mistaken for a complete distributed protocol.

Typestate moves some choices into the type system

A different design uses separate types such as DraftDocument and ReadyDocument. Validating a draft returns a ready document, and only the ready type exposes a send method. This is often called typestate. It can prevent certain method sequences from being expressed by ordinary callers, because a draft value simply has no send operation. Ownership can ensure that a successful transition consumes the old representation.

Typestate is useful when callers control the sequence in Rust code and the number of states remains manageable. It can become less convenient when a collection must store many objects in different states or when commands arrive dynamically. An enum is often a natural storage representation in those situations. The two approaches can also meet at a boundary, with an enum for persisted data and validated types for a narrower operation.

Do not choose typestate solely because “compile-time” sounds stronger. It has costs in API shape, generic types and recovery design. Choose the guarantees that simplify real callers. A runtime enum with precise errors can be a better product API than a complicated type hierarchy that people work around with duplicated data.

Sources: The Rust Book: state transitions and alternative type designs

Store a domain state, not a debug string

Persisted state lives longer than one version of the code. Avoid treating a Debug representation as a stable file format. A renamed variant or changed formatting can break old records. Define a deliberate storage representation with a versioning policy, and validate data when reading it back. External bytes can describe combinations your current in-memory constructors would never produce.

When migrating a flag-based format, handle the unintended combination explicitly. A stored record with sent = true and valid = false needs a product decision: reject it, mark it for recovery, or migrate it using trustworthy additional information. Silently mapping every unknown combination to Draft may hide a previously sent item and cause an unsafe duplicate action. A default can be data loss disguised as convenience.

Write migration fixtures for each old combination and any unsupported future value. A round-trip test is useful but insufficient by itself: encoding and decoding can agree with each other while both implement the wrong mapping. Include independently specified expected states for representative stored records.

Test the transition table, including rejected edges

Three states and three actions produce nine state-action pairs. Only three are legal in our minimal table. That is small enough to test every pair and verify both the returned result and the resulting state. This does not mean every larger state machine needs an enormous exhaustive Cartesian product. It means a small domain should not leave its illegal edges unexamined simply because the happy path passes.

Also test sequences. Validation followed by editing should return to draft, and a subsequent send should fail until validation happens again. The sequence exposes stale-validation defects that isolated successful transitions can miss. If states carry data, check that the right fields survive or change at each edge. A correct status label is not enough if the document contents were replaced accidentally.

For effects, test a seam with controlled outcomes rather than relying on random network availability. Verify that a failed validation triggers no send request, and that an ambiguous delivery outcome is represented according to the documented policy. The rule and its effects need related but distinct evidence.

Turn a rejected transition into useful interface feedback

The domain can reject a command without deciding how the interface should look. A screen might disable Send while the document is a draft and show a concise explanation when validation fails. Another caller might log a structured rejection. Keep the reason available rather than making every caller guess from the current label. In a larger API, a transition error enum can distinguish an invalid action from invalid content or a failed external operation.

A disabled button is helpful guidance, but it is not the enforcement boundary. Commands can arrive through shortcuts, restored sessions or a different screen, so the transition operation should still validate the rule. Tests can then check the domain independently of the visual affordance. This avoids a fragile design where one carefully styled button is the only thing preventing an invalid state change. The interface explains the rule; the domain consistently applies it wherever the command originates.

Grow the model only when a real rule needs it

An enum is especially useful when it replaces repeated conditionals scattered across screens, persistence code and command handlers. Put the transition decision in one place and let those callers ask it for an outcome. Keep display details separate where they do not affect the domain. A new button color should not require rewriting the state model, while a new approval requirement probably should.

Before adding a state, explain what it permits, what it forbids and which data distinguishes it from existing states. If two proposed states have identical behavior and data, they may be labels for the same state. If one variant needs several optional fields whose combinations keep producing new rules, the model may need another boundary or a more specific type.

Start with the diagram, encode the intended alternatives, define what failure preserves, and test legal and illegal transitions. That process turns “make invalid states impossible” from a slogan into a bounded engineering claim. You will know which combinations the representation excludes, which commands runtime validation rejects, and which external facts still need verification.

Explore the free Crabrix lessons →