Modeling Data with Structs and Enums
Two questions drive most data modeling: what does this thing have, and what can this thing be. Rust answers the first with structs and the second with enums, and the distinction between them is not stylistic - it reflects two different mathematical shapes that most other mainstream languages blur together.
A struct bundles fields that all exist at once: a User has an id and a name and an email. An enum describes a value that is exactly one of several alternatives: a Shape is a Circle or a Square, never both, never neither. Keeping those two shapes distinct - and forcing every use of an enum through exhaustive matching - is what lets Rust push a large category of "forgot to handle a case" bugs out of runtime and into the compiler.
This page is the conceptual anchor for the section. Structs Basics and Enums work through the syntax hands-on; this page explains why the two types exist as a pair and what that pairing buys you.
Summary
- Structs (product types) model "has all of these fields"; enums (sum types) model "is exactly one of these variants" - together they let you shape data so that only valid states can be constructed.
- Insight: Many production bugs come from a value being in a state the code never expected; sum types plus exhaustive matching move the burden of noticing "you missed a case" from testing and code review onto the compiler.
- Key Concepts: product type, sum type, variant, exhaustive matching, making illegal states unrepresentable, pattern matching.
- When to Use This Model: Designing any type that has a finite set of distinct modes (request state, connection status, parse result), and any type where a combination of fields would otherwise be invalid together.
- Limitations/Trade-offs: Enums can only hold data shapes you defined ahead of time, and adding a variant is a breaking change for every exhaustive
matchon that type across the codebase - which is the safety mechanism, but it does mean changes ripple outward. - Related Topics: algebraic data types,
Option/Resultas enums, pattern matching, state machines.
Foundations
A product type is called that because the number of possible values it can hold is the product of the possible values of each field. A struct with a bool field and a u8 field can represent 2 x 256 distinct combinations, and Rust's struct is exactly this: every field is present, all the time, for every instance.
struct User {
id: u64,
is_admin: bool,
}A sum type is called that because its possible values are the sum of the possible values across its variants, not the product. Rust's enum is a sum type: a value is one variant, chosen from a fixed list, and only that variant's data exists for that value.
enum Connection {
Disconnected,
Connecting { attempt: u8 },
Connected { session_id: u64 },
}A Connection value is never Disconnected and Connected at the same time, and it is never neither. Compare this to a common pattern in languages without sum types: a struct with an is_connected: bool field and a separate, optional session_id: Option<u64> field, where nothing in the type stops is_connected == false while session_id is still Some(...). That combination is nonsensical, but the type system has no way to say so - it is a bug waiting for the wrong two fields to disagree at runtime.
This is the core idea behind making illegal states unrepresentable: instead of writing code that checks whether a combination of fields makes sense, you design the type so the nonsensical combination cannot be constructed in the first place.
Mechanics & Interactions
Enums earn their safety through exhaustive matching. When you match on an enum, the compiler requires every variant to be handled, either explicitly or through a wildcard _ arm.
fn describe(c: &Connection) -> String {
match c {
Connection::Disconnected => "idle".into(),
Connection::Connecting { attempt } => format!("retry #{attempt}"),
Connection::Connected { session_id } => format!("session {session_id}"),
// add a new variant to Connection, and every match like
// this one across the codebase fails to compile until fixed
}
}This is the mechanism, not just a nice property. If Connection gains a Reconnecting variant next quarter, every exhaustive match on Connection anywhere in the codebase becomes a compile error until someone decides what that new state means at each call site. In a language with a runtime switch/default or an if/else if chain over string tags, the equivalent change compiles fine and silently falls through to whatever the default case does - which is very often the wrong behavior, discovered later, in production.
Structs and enums combine constantly in practice. Enum variants can hold struct-shaped data (as Connecting { attempt: u8 } does above), and structs can hold enum fields to represent a piece of state that varies. Option<T> and Result<T, E>, arguably the two most-used types in Rust, are both ordinary enums built from exactly this vocabulary: Option<T> is Some(T) or None, Result<T, E> is Ok(T) or Err(E), and both are matched exhaustively like any other enum.
Destructuring extends this pattern into let bindings and function parameters, not just match arms. let User { id, .. } = user; pulls a field out by pattern, and the same exhaustiveness reasoning applies wherever a pattern is required to fully account for a value's shape.
Advanced Considerations & Applications
Modeling a "one of several states" value is not unique to enums - other approaches exist, with real trade-offs.
| Approach | Strength | Weakness | Best Fit |
|---|---|---|---|
Enum + exhaustive match (Rust) | Compiler-enforced completeness; illegal combinations unrepresentable | New variants are a breaking change everywhere they're matched | Any fixed, closed set of mutually exclusive states |
| Struct with boolean/string "tag" fields | Familiar, flexible, easy to add fields incrementally | Nothing prevents invalid field combinations; checks are runtime and easy to skip | Rapid prototyping where invariants are not yet settled |
| Class hierarchy / inheritance (OOP) | Extensible by third parties without modifying the base type | Open-ended - the compiler can't enumerate "all possible subtypes" to check exhaustiveness | Plugin-style systems where the set of variants is deliberately unbounded |
Trait objects (dyn Trait) | Open set of implementers, runtime polymorphism | No exhaustiveness check at all - by design, since the set is open | Behavior-defined extensibility rather than closed state modeling |
The breaking-change cost of adding an enum variant is a deliberate feature, not friction to route around. It means the compiler finds every place your codebase makes an assumption about the closed set of possibilities, at the moment that assumption becomes false, rather than leaving it for a future maintainer to discover by reading a bug report. For genuinely open-ended extensibility - plugins, user-defined behaviors - that same rigidity is the wrong tool, which is why Rust also offers trait objects for cases where the set of "kinds of thing" should stay open (see the Traits & Generics section).
This modeling discipline scales into architecture. State machines map almost directly onto enums, with each state as a variant and each transition as a method that consumes one variant and produces another, so an invalid transition simply has no code path that produces it. Domain modeling in larger systems leans on the same idea: parsing external input (JSON, a form submission) into an enum as early as possible converts "is this data valid" from an ongoing runtime concern into a one-time boundary check, after which every downstream function can trust the shape it was handed.
Common Misconceptions
- "Enums in Rust are just like enums in C or Java - a set of named integer constants." Rust enum variants can carry arbitrary, differently-shaped data per variant (
Connecting { attempt: u8 }versusConnected { session_id: u64 }); they are closer to tagged unions than to C's plain integer enums. - "A struct with optional fields is basically the same as an enum." A struct with several
Option<T>fields still allows every combination ofSome/Noneto be constructed, including nonsensical ones; an enum restricts the value to exactly one coherent shape at a time. - "Exhaustive matching is just extra typing for the same runtime behavior." It changes when a missing case is caught - compile time instead of runtime - which is the entire safety benefit, not a cosmetic difference.
- "Adding a wildcard
_arm to a match is always fine." A wildcard silently absorbs any future variant you add, which quietly defeats exhaustiveness checking for that call site - it should be a deliberate choice, not a reflex to make the compiler stop complaining. - "You should use enums for everything that has 'a few options.'" If the set of options is meant to grow indefinitely from outside your crate (plugins, user-defined behavior), a trait is usually the better fit than an enum, which assumes a closed, known set of variants.
FAQs
What's the difference between a product type and a sum type, in plain terms?
A product type (struct) holds all of its fields at once, so its possible values multiply together; a sum type (enum) holds one of its variants at a time, so its possible values add together across variants.
Why can enum variants hold different data from each other?
Because each variant represents a genuinely different state with potentially different information attached - a Connecting state needs an attempt count, a Connected state needs a session ID, and there's no reason to force both fields to exist for every state.
How does exhaustive `match` actually prevent bugs at compile time?
The compiler tracks every variant of the enum type being matched and requires an arm (or a wildcard) for each one. If a variant is unhandled, or a new variant is added later without updating that match, compilation fails at that exact line instead of the program running with a silently-skipped case.
Is `Option<T>` really just an enum like any other?
Yes - Option<T> is defined as enum Option<T> { Some(T), None } in the standard library, with no special-case compiler magic beyond what any enum gets. Matching it exhaustively works exactly like matching a custom enum.
When should I reach for an enum instead of a struct with a status field?
When the "status" implies different data is relevant or valid depending on which status it is. If a Connecting status has an attempt count that makes no sense for Disconnected, an enum variant per status prevents that field from existing when it shouldn't.
Does adding a new enum variant really break existing code?
Yes, deliberately - any exhaustive match on that enum without a wildcard arm fails to compile until it accounts for the new variant. This is the mechanism that finds every place your code assumed a closed, smaller set of possibilities.
What does "making illegal states unrepresentable" mean in practice?
It means designing the type so that a value which shouldn't exist - like "connected but with no session ID," or "disconnected but still retrying" - simply cannot be constructed, rather than relying on code elsewhere to check for and reject that combination at runtime.
Are trait objects a replacement for enums?
No - they solve a different problem. Enums model a closed, known set of variants with exhaustiveness checking; trait objects model an open set of implementers where new "kinds" can be added without modifying the original type, at the cost of losing exhaustiveness entirely.
Why does a wildcard `_` arm in a match weaken this safety?
A wildcard matches any variant not explicitly listed, including ones added after the match was written. That means a future variant addition compiles silently and falls into whatever the wildcard does, which defeats the "compiler catches missed cases" guarantee for that call site.
How do structs and enums combine in real code?
Constantly - enum variants often carry struct-shaped data (named fields per variant), and structs often have fields typed as an enum to represent a piece of state that varies. Result<T, E> inside a struct field is a common example: the struct always exists, but one of its fields can be in one of several states.
Is this modeling style unique to Rust?
No - sum types and pattern matching exhaustiveness come from the ML/Haskell family of languages and appear in Swift, Kotlin (sealed classes), TypeScript (discriminated unions), and others. Rust's version is notable mainly for how central it is to idiomatic code, including the standard library's own Option and Result.
What's the cost of designing types this way?
Upfront design effort: you have to actually enumerate the valid states before writing code, rather than adding fields and flags incrementally as needs arise. That cost tends to pay for itself later, when the enum's closed set catches a case you would otherwise have missed by hand.
Related
- Structs Basics - the hands-on walkthrough of product types
- Enums - the hands-on walkthrough of sum types
- Pattern Matching with match - exhaustive matching mechanics in depth
- Option & Result as Enums - the standard library's own sum types
- Enums for State Machines - applying this model to stateful systems
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.