Skip to content
← All Rust articles

Rust explainer

Choose what belongs in a struct or enum

About 5 min to read. Allow extra time to work through examples.

Compare shared fields with variant-specific data, model independent choices, and understand which invariants your types enforce.

This article uses Rust. You can make it your practice mode to browse related content across the site.

A struct groups values that exist together. An enum selects one alternative from a set of variants. Many data models need both: some information belongs to every value, while other information depends on its kind.

Consider a drawing program. Every shape has an identifier and a colour. Circles have a radius, rectangles have a width and height, and polygons have a list of points.

Two ways to represent the same information

The first model puts all the fields inside each enum variant:

#[derive(Clone, Copy)]
enum Colour { Red, Blue }

enum ShapeA {
    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)> },
}

The second stores shared fields once and puts only the varying information in an enum. It uses the same Colour type:

struct ShapeB {
    id: u32,
    colour: Colour,
    geometry: Geometry,
}

enum Geometry {
    Circle { radius: f64 },
    Rect { width: f64, height: f64 },
    Polygon { points: Vec<(f64, f64)> },
}

Both representations pair the right geometry fields with the selected kind. Neither validates the numbers: a negative radius or an insufficient number of polygon points is still representable. If those values are invalid in your application, add validation and control construction through your API.

Shared fields need different access code

An accessor can hide the first model's matching from its callers:

impl ShapeA {
    fn id(&self) -> u32 {
        match self {
            Self::Circle { id, .. }
            | Self::Rect { id, .. }
            | Self::Polygon { id, .. } => *id,
        }
    }
}

This is one match arm with three alternatives. Adding a variant requires updating this exhaustive accessor, but callers can continue using shape.id(). It does not force every caller to match on geometry.

In the second model, code with field access can simply read shape.id. Geometry changes do not affect that access. You can also provide an accessor if the field should stay private.

Change Fields inside variants Shared fields plus geometry
Add a shared field Update every variant and its construction sites Update the struct and its construction sites
Add a geometry kind Add a variant and revisit exhaustive shape matches Add a geometry variant and revisit exhaustive geometry matches
Compute area Match on ShapeA Match on shape.geometry
Read the identifier Use an accessor or a match Read the field or use an accessor

For this domain, ShapeB makes the shared information easy to work with. ShapeA remains useful when fields differ between variants, or when grouping each variant's complete payload makes the API clearer. Similar field names alone do not establish that the fields mean the same thing.

Independent choices can use separate enums

Suppose a payment has a method and a status. If every method can have every status, model them independently:

enum Method { Card, BankTransfer, Wallet }
enum Status { Pending, Authorised, Captured, Failed }

struct Payment {
    amount_cents: u64,
    method: Method,
    status: Status,
}

This represents twelve method/status combinations without twelve named variants. A function concerned only with status can match on that field. A function concerned with both can match on (&payment.method, &payment.status).

Check your understanding What changes if your application's rules disallow one of those twelve combinations? Show answer

The two-enum struct still permits it. An enum containing only the valid combinations can exclude it structurally. Another option is a type with private fields and constructors or transition methods that validate the pair. Method-specific status types may also fit the domain.

Choose based on which combinations and transitions are legal. Merely omitting an allowed case from a flat enum is a modelling omission, not a way of creating an illegal value. Conversely, separate enums do not establish that the choices are independent; that has to come from the domain rules.

Rust checks whether a match covers the values its type can represent. It does not know an application's business rules unless the representation or API enforces them. Ordinary struct fields still have type checking; they simply do not need an exhaustive match to be accessed.

Measure layout instead of assuming a winner

Neither shape representation is guaranteed to be smaller. Rust's default enum layout can use padding and unused bit patterns, so “largest payload plus a tag” is not an exact size formula. Field placement and alignment affect the surrounding struct too.

Measure both complete types with std::mem::size_of::<ShapeA>() and std::mem::size_of::<ShapeB>() for your compiler and target. Comparing ShapeA with Geometry alone would omit ShapeB's shared fields.

size_of measures inline storage, including padding. It does not include a polygon vector's separately allocated points. Boxing a large payload may reduce an enum's inline size, but adds allocation and indirection; measure the workload before choosing that trade-off.

A separate choice: rows or columns

A collection of complete shapes is one layout. Separate vectors for each field are another:

struct Shapes {
    ids: Vec<u32>,
    colours: Vec<Colour>,
    geometries: Vec<Geometry>,
}

Separate vectors can improve locality when an operation reads only one field across many shapes. They are not universally faster: access patterns, allocation and updates affect the result.

They also introduce an invariant: the vectors must have equal lengths, and entries at the same index must refer to the same shape. Equal lengths alone would not detect accidentally reordering just one vector.

Keep the vectors private and expose operations that update them together. When implementing insertion or removal, consider failure paths as well as successful updates; privacy gives you a place to enforce the invariant, but does not prove the implementation correct. Tests can check these operations, while benchmarks can help compare layouts.

Start by identifying which data is shared, which alternatives exclude each other, and which choices vary independently. Then choose a representation that makes the important rules straightforward to express and maintain.

step 26 of 26

Shape Your Data: Structs, Enums, Pattern Matching

You've reached the last step

Revisit the track to review its lessons and exercises, or explore another topic.

Explore more tracks