Structs, Enums, and Exhaustive States
Structs describe values that exist together. Enums describe alternatives where exactly one variant is active. This product-versus-alternative distinction is the foundation of strong Rust domain models.
The runnable example is
rust/rust-ai-engineering/crates/mosaic-domain/examples/states.rs.
Learning objectives
Section titled “Learning objectives”After completing this chapter, you should be able to:
- choose a struct, tuple struct, newtype, or enum based on domain meaning;
- attach data to enum variants;
- use exhaustive
matchas a change detector; - destructure structs and enums without unnecessary cloning;
- distinguish irrefutable and refutable patterns;
- use
if letandlet...elsewithout hiding important alternatives; - redesign boolean-heavy state into valid-by-construction types;
- explain enum layout trade-offs without premature optimization.
What this chapter depends on
Section titled “What this chapter depends on”You should understand values, functions, block expressions, and match from
Expressions, Bindings, and Control Flow. We postpone
borrowing details; this chapter uses references only where the intent is obvious.
First principle: product or alternative?
Section titled “First principle: product or alternative?”A task has an identifier and an objective and a budget. That is a product:
struct Task { id: TaskId, objective: String, budget: RunBudget,}A run is preparing or generating or verifying or completed or failed. That is an alternative:
enum RunState { Preparing, Generating { attempt: u32 }, Verifying(Draft), Completed(Verified), Failed { reason: String },}Each enum value contains one variant at a time. The Rust Reference describes an enum as a nominal, heterogeneous disjoint union.
This is stronger than:
struct WeakRunState { preparing: bool, generating: bool, completed: bool, failed: bool, output: Option<String>, error: Option<String>,}The weak representation permits nonsense:
completedandfailedare both true;completedis true but output is absent;failedis false but error exists;- no phase is active;
- multiple phases are active.
Code must repeatedly defend against states the type itself allowed.
Struct forms and their meaning
Section titled “Struct forms and their meaning”Named-field struct
Section titled “Named-field struct”Use when field names explain the value:
struct ModelUsage { input_units: u64, output_units: u64,}Construction is self-documenting:
let usage = ModelUsage { input_units: 80, output_units: 24,};Tuple struct
Section titled “Tuple struct”Use when position is conventional and names add little:
struct Dimensions(u32, u32);For public APIs, named fields are often clearer than more than two positional values.
Newtype
Section titled “Newtype”A one-field tuple struct creates a distinct semantic type:
struct RunId(String);struct ModelId(String);Now a function expecting RunId cannot accidentally receive a ModelId, even though both use
String internally.
Newtypes can centralize validation, display, serialization, and secrecy. Do not expose their inner field publicly if callers must preserve an invariant.
Unit-like struct
Section titled “Unit-like struct”A fieldless struct can name behavior:
struct JsonContractVerifier;It carries no data, but its type can implement Verifier.
Methods preserve invariants
Section titled “Methods preserve invariants”Constructors are ordinary associated functions:
impl RunId { pub fn new(value: impl Into<String>) -> Result<Self, DomainError> { let value = value.into(); if value.trim().is_empty() { return Err(DomainError::EmptyIdentifier("run")); } Ok(Self(value)) }}The field remains private. Callers cannot construct RunId(String::new()).
Method receiver choices communicate ownership:
fn as_str(&self) -> &str // borrow for readingfn rename(&mut self, ...) // borrow for mutationfn into_string(self) -> String // consume and transfer ownershipChoose the weakest receiver that satisfies the behavior.
Enum variants carry exactly the relevant data
Section titled “Enum variants carry exactly the relevant data”The runnable run state uses three variant shapes:
enum RunState { Preparing, // unit-like Generating { attempt: u32 }, // struct-like Verifying(Draft), // tuple-like Completed(Verified), Failed { reason: String },}Completed always contains a Verified value. Verifying contains a draft, not a verified output.
Failed carries its reason only when failure is the active state.
This does not yet prevent every illegal transition. A caller could construct Completed directly if
the variant is public. Later, typestate and module visibility will restrict construction.
Exhaustive match is a maintenance alarm
Section titled “Exhaustive match is a maintenance alarm”fn describe(state: &RunState) -> &'static str { match state { RunState::Preparing => "preparing", RunState::Generating { .. } => "generating", RunState::Verifying(_) => "verifying", RunState::Completed(_) => "completed", RunState::Failed { .. } => "failed", }}Add a new variant:
AwaitingApproval { capability: Capability }Every exhaustive match now fails to compile until it decides how approval should be displayed, persisted, measured, and handled. The compiler has converted a product change into a concrete work list.
A wildcard defeats that advantage:
match state { RunState::Completed(output) => publish(output), _ => {}}This may be correct when all non-completed states intentionally share behavior. At policy boundaries, explicit arms are usually safer.
Destructuring binds the parts you need
Section titled “Destructuring binds the parts you need”match state { RunState::Generating { attempt } => { println!("attempt {attempt}"); } RunState::Failed { reason } => { eprintln!("{reason}"); } _ => {}}Match ergonomics allow matching &RunState while borrowing contained fields. You do not need to clone
the String merely to print it.
Structs can be destructured too:
let ModelUsage { input_units, output_units,} = usage;Use .. to ignore remaining fields when the match is intentionally partial:
let ModelUsage { output_units, .. } = usage;Inside the defining crate, requiring all fields can be a useful signal when the struct changes.
Refutable and irrefutable patterns
Section titled “Refutable and irrefutable patterns”An irrefutable pattern always matches:
let ModelUsage { input_units, output_units,} = usage;The value must have those fields because its type is ModelUsage.
An enum pattern is usually refutable:
let RunState::Completed(output) = state;The state might be another variant, so plain let rejects it. Use:
let RunState::Completed(output) = state else { return Err(NotReady);};After the statement, output is available and the non-completed path has exited.
Use if let when the body is optional:
if let RunState::Failed { reason } = &state { record_failure(reason);}Use full match when different variants require different work.
Invalid states and invalid transitions
Section titled “Invalid states and invalid transitions”An enum solves representation, not transition authorization. Define transitions in one module:
impl RunState { fn begin(self) -> Result<Self, TransitionError> { match self { Self::Preparing => Ok(Self::Generating { attempt: 1 }), other => Err(TransitionError::CannotBegin(other)), } }}Consuming self ensures the old state cannot be used after transition unless the method returns it.
For dangerous workflows, use different wrapper types:
struct DraftPlan(Plan);struct VerifiedPlan(Plan);struct ApprovedPlan(Plan);
fn verify(plan: DraftPlan) -> Result<VerifiedPlan, VerifyError>;fn approve(plan: VerifiedPlan, approval: HumanApproval) -> ApprovedPlan;fn execute(plan: ApprovedPlan) -> Result<Receipt, ExecuteError>;No function accepts a draft where approval is required.
Run the example
Section titled “Run the example”From rust/rust-ai-engineering:
cargo run -p mosaic-domain --example statesExpected output:
preparing → generating → verifying → completed → failedThe example intentionally constructs every variant so the match and output sequence are easy to inspect.
Layout and performance
Section titled “Layout and performance”An enum must store its largest variant plus a discriminant. A variant with a very large buffer can therefore make every enum value large.
Measure before changing the domain. If size matters:
- put a large payload behind
Box,Arc, or an artifact reference; - split hot metadata from cold payload;
- avoid storing decoded media directly in run state;
- benchmark the real collection and access pattern.
Do not replace a clear enum with integer tags and untyped maps merely to guess at layout savings.
Serialization boundaries
Section titled “Serialization boundaries”Serde can encode tagged enums:
{ "type": "run_failed", "status": "invalid_output", "message": "repair budget exhausted"}Wire format is an API. Decide:
- internal versus externally tagged representation;
- stable variant and field names;
- unknown future variants;
- whether old readers must accept new writers;
- migration and replay compatibility.
Do not rename a persisted trace variant casually.
Failure investigation
Section titled “Failure investigation”“Pattern does not mention field”
Section titled ““Pattern does not mention field””The struct gained a field. Decide whether the code needs it. Add the field or .. intentionally.
“Non-exhaustive patterns”
Section titled ““Non-exhaustive patterns””An enum gained a variant or the match missed one. Treat the compiler output as a policy checklist.
Moving a value out of a shared reference
Section titled “Moving a value out of a shared reference”The pattern attempted to take ownership from &state. Borrow the field, match by reference, or redesign
the caller to consume the state. Do not clone automatically.
Boolean state keeps growing
Section titled “Boolean state keeps growing”Stop adding flags. Write the valid states and transitions in plain language, then model alternatives as an enum with variant-specific data.
Security and performance implications
Section titled “Security and performance implications”Security states should be explicit:
enum Authorization { Denied(DenialReason), ReadOnly(ReadGrant), ApprovedWrite(WriteGrant),}An authorized: bool cannot carry scope, actor, expiry, or capability.
Use small identifiers or artifact references in long-lived state. Large image, audio, video, and tensor buffers need deliberate ownership and lifetime policy.
Exercises
Section titled “Exercises”Recall
Section titled “Recall”- Why is
Taska struct whileRunStateis an enum? - What maintenance signal is lost when a policy match uses
_? - Why can a newtype prevent mixing model and run identifiers?
Implementation
Section titled “Implementation”- Add
AwaitingApproval { capability: String }to the runnable example and update every match. - Replace the example’s free-form failure string with
FailureReasonvariants. - Implement
RunState::beginandRunState::submit(Draft)as consuming transitions. - Serialize every state with Serde and add a round-trip test.
Design
Section titled “Design”- Design states for generated video: draft brief, storyboard candidates, selected storyboard, rendered preview, human approved, published, rejected.
- Decide which variants may be persisted and resumed after a crash.
- Decide how an old binary should handle a future persisted variant.
Solution guidance
Section titled “Solution guidance”Give approval and publication their own variants; do not encode them as booleans on a general artifact. Store receipts or approvals only on states that have them.
Use transition methods in a module that owns private variants or inner fields. If persistence needs an open-ended forward-compatible envelope, version the serialized form separately from the internal enum.
Check your understanding
Section titled “Check your understanding”Can an enum guarantee legal transitions?
It guarantees one represented alternative at a time. Legal transitions require private construction, transition methods, typestate wrappers, or a combination of these.
Why attach data to variants instead of optional fields?
Variant data exists exactly when that state is active. Optional fields on one large struct permit combinations that do not make sense and force repeated runtime checks.
Completion checklist
Section titled “Completion checklist”- run the companion state example;
- add an approval state and follow the compiler errors;
- model one real workflow without boolean state flags;
- explain named, tuple, newtype, and unit-like structs;
- identify one refutable and one irrefutable pattern;
- describe the serialized compatibility policy for the enum.