By now you can write both shapes. A struct that holds an enum:
struct Event { kind: Kind, timestamp: u64, source: String }
enum Kind { Click, KeyPress, Scroll }
And an enum whose variants are structs:
enum Event {
Click { timestamp: u64, source: String, x: i32, y: i32 },
KeyPress { timestamp: u64, source: String, key: char },
Scroll { timestamp: u64, source: String, delta: i32 },
}
The interesting question is not how to write them. It is which one, and that judgement is what separates knowing Rust’s features from designing with them.
This item is an article because both models pass the identical test suite. Feed either one the same inputs and you get the same outputs; the difference lives entirely in what the compiler can check for you and what a future change costs. A unit test cannot see any of that, and pretending otherwise would be dishonest.
The domain
Let us make it concrete. A drawing program records shapes. Every shape has an
id and a colour. Circles have a radius; rectangles have a width and a
height; polygons have a list of points.
Model A — enum of structs
enum Shape {
Circle { id: u32, colour: Colour, radius: f64 },
Rect { id: u32, colour: Colour, width: f64, height: f64 },
Polygon { id: u32, colour: Colour, points: Vec<(f64, f64)> },
}
Model B — struct of enums
struct Shape {
id: u32,
colour: Colour,
geometry: Geometry,
}
enum Geometry {
Circle(f64),
Rect(f64, f64),
Polygon(Vec<(f64, f64)>),
}
Model C — the underrated one: common plus kind
Model B is the common-plus-kind split, and calling it out by that name matters, because most people arrive at Model A first and never consider it. The shared fields live in the struct once; the varying part lives in a small enum. You keep exhaustive matching on exactly the part that varies, and you stop duplicating everything else.
💡Before reading on: what is the single biggest practical difference between A and B for a function that only needs the id? click to reveal
In Model A, a function that just wants the id must match on the whole
Shape and pull id out of each of the three arms. Add a fourth variant and
that function stops compiling — even though it does not care about geometry
at all.
In Model B it is shape.id. One field access, no match, and adding a
geometry variant does not touch it.
This is the crux. Model A makes every consumer pay attention to the axis of variation, whether or not they care about it. Model B makes only the geometry-aware consumers pay.
The measurements
Numbers make the trade-off less arguable. For the three-shape domain above:
| Model A (enum of structs) | Model B (common + kind) | |
|---|---|---|
| Variants to declare | 3 | 3 |
Times id is written in the declaration |
3 | 1 |
Times colour is written |
3 | 1 |
| Match sites for “get the id” | 1 match, 3 arms | 0 |
| Match sites for “compute the area” | 1 match, 3 arms | 1 match, 3 arms |
Cost of adding colour_space: Space |
edit 3 variants | edit 1 struct |
Cost of adding a Line shape |
1 variant + every geometry match | 1 variant + every geometry match |
And on size_of. Rust lays an enum out as a discriminant plus the largest
variant, so the whole enum is as big as its fattest member:
-
Geometryin Model B is dominated byPolygon(Vec<..>)— aVecis three words (pointer, length, capacity), so 24 bytes on a 64-bit target, plus the discriminant, plus padding to thef64alignment. -
Model A’s
Shapecarriesidandcolourinside every variant, so the layout has to accommodate the widest variant’s total payload. It is strictly larger.
This is where clippy::large_enum_variant shows up. It fires when one
variant is dramatically bigger than the others — the classic case being one
arm holding a big array or a wide struct while the rest hold nothing — and it
suggests boxing the big one. Model A makes that scenario more likely, because
each variant is the union of “the thing that varies” and “everything shared”.
The rule of thumb
Ask: does the shared data mean the same thing in every variant?
- Yes → common + kind (Model B). Hoist the shared fields into a struct. Every consumer that only wants the shared part stops caring about the variation entirely.
- No → enum of structs (Model A). If the “id” of a circle and the “id” of a polygon are genuinely different concepts that happen to share a name, hoisting them creates a false abstraction and you will end up asserting invariants in comments.
And ask a second question: which axis is the one the compiler should police? Exhaustiveness only helps you on the axis you chose to put in the enum. Everything else becomes ordinary field access with no checking at all.
💡A payment system has three methods (card, bank transfer, wallet) and four statuses (pending, authorised, captured, failed). Model it. What goes wrong if you make one enum with twelve variants? click to reveal
Twelve variants is the variant-explosion failure mode, and it goes wrong in three specific ways.
The match sites multiply. A function that only cares about status now has twelve arms instead of four. Add a fourth payment method and it becomes sixteen — you edit a function whose logic did not change.
The illegal states come back. With twelve hand-written variants, nothing
stops you from forgetting WalletFailed, or from writing it with a slightly
different payload than CardFailed. The type system is no longer generating
the cross product for you; you are typing it out, and typing it out is where
mistakes live.
It hides which axes are independent. The whole point of two enums is that method and status vary independently. One flat enum asserts that they do not.
The right model is two enums in one struct:
struct Payment {
amount: Cents,
method: Method,
status: Status,
}
You keep exhaustiveness on each axis separately, and a function that
dispatches on both writes match (payment.method, payment.status) — which
the compiler will still check exhaustively over the pair, but only in the
one place that actually needs it.
The exception: if some combinations are genuinely impossible — say a wallet
payment can never be Authorised because wallets capture immediately — then
the flat model does buy you something, because you can simply not declare
that variant. Now the illegal state is unrepresentable rather than merely
undocumented. That is a real trade, and it is the only good reason to accept
the explosion.
Where the track’s own thesis gets uncomfortable
This track has argued throughout that you should model so illegal states do not compile. It is worth being honest that the performance-driven version of this refactor works against that.
Struct-of-arrays — the layout you reach for when you are iterating over a million shapes and want the colours contiguous in cache — looks like this:
struct Shapes {
ids: Vec<u32>,
colours: Vec<Colour>,
geometries: Vec<Geometry>,
}
It is dramatically faster for column-wise access, and it reintroduces an
illegal-state risk that the struct-of-structs version did not have: the
three vectors can desynchronise. Push to one and not the others and you have
a Shapes whose ids[7] and colours[7] describe different shapes. No type
prevents it. You are back to an invariant maintained by discipline.
That is a genuine tension, not a footnote. The usual resolution is to make
the desynchronised state unconstructable at the boundary — private fields, a
single push(Shape) method that appends to all three vectors, no public
access to the individual Vecs — which is exactly the privacy lesson from
earlier in this track, applied where it is load-bearing.
Lints that comment on this decision
-
large_enum_variant— one variant much bigger than the rest; box it. Frequently a symptom of Model A on a domain where one shape carries a lot. -
struct_excessive_bools(allow-by-default) — more than threeboolfields usually means you have several enums hiding as booleans. Three booleans are eight states, most of which you have not thought about. -
option_option(allow-by-default) —Option<Option<T>>is almost always a three-state enum that wants a name. -
enum_variant_names— variants repeating the enum’s name, which tends to show up when Model A’s variants are trying to be self-describing because they are doing too many jobs.
Summary
- Both models pass the same tests. The difference is what a change costs.
- Hoist genuinely shared data into a struct and keep the varying part in a small enum — the common-plus-kind split. It is the default that most people skip.
- Put in the enum whichever axis you want the compiler to police, and be aware that you get exhaustiveness on that axis and nothing else.
- Independent axes want independent enums. A flat cross product is only right when some combinations are genuinely impossible and you want that impossibility to be a type error.
- Struct-of-arrays buys throughput and gives back an invariant. If you take that trade, defend it with privacy.