Skip to content

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.

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 match as a change detector;
  • destructure structs and enums without unnecessary cloning;
  • distinguish irrefutable and refutable patterns;
  • use if let and let...else without hiding important alternatives;
  • redesign boolean-heavy state into valid-by-construction types;
  • explain enum layout trade-offs without premature optimization.

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.

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:

  • completed and failed are both true;
  • completed is true but output is absent;
  • failed is false but error exists;
  • no phase is active;
  • multiple phases are active.

Code must repeatedly defend against states the type itself allowed.

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,
};

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.

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.

A fieldless struct can name behavior:

struct JsonContractVerifier;

It carries no data, but its type can implement Verifier.

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 reading
fn rename(&mut self, ...) // borrow for mutation
fn into_string(self) -> String // consume and transfer ownership

Choose 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.

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.

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.

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.

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.

From rust/rust-ai-engineering:

Terminal window
cargo run -p mosaic-domain --example states

Expected output:

preparing → generating → verifying → completed → failed

The example intentionally constructs every variant so the match and output sequence are easy to inspect.

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.

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.

The struct gained a field. Decide whether the code needs it. Add the field or .. intentionally.

An enum gained a variant or the match missed one. Treat the compiler output as a policy checklist.

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.

Stop adding flags. Write the valid states and transitions in plain language, then model alternatives as an enum with variant-specific data.

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.

  1. Why is Task a struct while RunState is an enum?
  2. What maintenance signal is lost when a policy match uses _?
  3. Why can a newtype prevent mixing model and run identifiers?
  1. Add AwaitingApproval { capability: String } to the runnable example and update every match.
  2. Replace the example’s free-form failure string with FailureReason variants.
  3. Implement RunState::begin and RunState::submit(Draft) as consuming transitions.
  4. Serialize every state with Serde and add a round-trip test.
  1. Design states for generated video: draft brief, storyboard candidates, selected storyboard, rendered preview, human approved, published, rejected.
  2. Decide which variants may be persisted and resumed after a crash.
  3. Decide how an old binary should handle a future persisted variant.

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.

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.

  • 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.