Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Modeling Player State with Structs, Enums, and Pattern Matching

Good to see you again. In the previous lesson, you separated ownership from access: a player-state value can remain owned by one place while helpers inspect it through &T or update it through &mut T. That gives us safe access, but the state itself is still only a collection of fields.

This lesson gives that state a meaningful shape. You will use structs for facts that coexist, enums for mutually exclusive playback modes, and exhaustive match expressions to make every mode explicit. This is a foundation for the later async player: asynchronous events such as “segment loaded,” “play requested,” and “network failed” only make sense when the player’s current state is modeled clearly.


Structs group facts that belong together

A struct is appropriate when several values describe one thing and may all be true or present at the same time. For example, a track has a title and duration; a player also has a volume and mute setting.

Defining and Instantiating Structs - The Rust Programming ...

Read the opening of “Defining and Instantiating Structs” from The Rust Programming Language. It establishes why named fields communicate intent more clearly than bare tuples and shows the basic construction syntax.

In the section “Defining and Instantiating Structs,” read the opening explanation, through the discussion immediately before Listing 5-2. Focus on the distinction between a struct definition, which defines a type, and an instance, which supplies concrete field values.

For a media player, this is a reasonable use of structs:

#[derive(Debug)]
struct Track {
    title: String,
    duration_ms: u64,
}

#[derive(Debug)]
struct PlayerState {
    volume: u8,
    muted: bool,
    playback: PlaybackState,
}

The named fields make the data self-describing:

  • duration_ms is not merely “the second number in a tuple.”
  • volume and muted can coexist independently of the current playback mode.
  • playback is a separate concept with more structure than a boolean can convey.

The use of String is deliberate. A player state typically needs to own its track metadata for as long as the state exists. Storing a borrowed &str inside a long-lived struct would require lifetime annotations and would tie the state to somebody else’s data. For now, an owned String gives the state a straightforward lifetime.

#[derive(Debug)] asks Rust to generate a developer-oriented representation, so that this is available while debugging:

println!("{:#?}", player);

It is not application-facing formatting; it is a useful way to inspect a nested state value while developing.

A struct is not automatically the right model for alternatives

Consider this tempting design:

struct PlaybackFlags {
    is_loading: bool,
    is_playing: bool,
    is_paused: bool,
    has_ended: bool,
}

It compiles, but its fields permit contradictory combinations:

let impossible = PlaybackFlags {
    is_loading: true,
    is_playing: true,
    is_paused: true,
    has_ended: true,
};

The type says this value is valid even though the player cannot meaningfully be loading, playing, paused, and ended at once. The program would need extra conventions such as “is_playing wins over is_paused,” and every consumer would need to remember them.

That is the signal to use an enum.


Enums represent one state from a defined set

An enum says that a value is exactly one of several variants. A playback state is naturally exclusive: at a particular instant, the player might be idle, loading, ready, playing, paused, ended, or failed. Each mode may need different associated data.

Enums and Pattern Matching in Rust

Watch “Enums and Pattern Matching in Rust” from Let’s Get Rusty. It introduces the central design decision behind enums, then shows variants carrying different data and the role of exhaustive match.

Start with the enum choice, which explains why a closed set of alternatives belongs in an enum rather than a struct. Then watch payload variants to see how each variant can store its own kind of data. Finish with exhaustive matches; focus on how patterns both identify a variant and bind the data stored inside it.

Here is a playback enum for the player:

#[derive(Debug)]
enum PlaybackState {
    Idle,

    Loading {
        source: String,
    },

    Ready {
        track: Track,
        buffered_ms: u64,
    },

    Playing {
        track: Track,
        position_ms: u64,
        buffered_ms: u64,
    },

    Paused {
        track: Track,
        position_ms: u64,
        buffered_ms: u64,
    },

    Ended {
        track: Track,
    },

    Failed {
        source: String,
        message: String,
    },
}

Each variant is still a PlaybackState, but its shape differs:

Variant form Example Meaning
Unit-like variant PlaybackState::Idle A state requiring no additional data
Struct-like variant PlaybackState::Loading { source } A state with named, descriptive fields
Struct-like variant with several fields PlaybackState::Playing { track, position_ms, buffered_ms } A state with several associated facts

Rust also supports tuple-like enum variants, such as Seeking(u64). For state that carries several domain values, named fields are usually clearer:

// Less descriptive:
Seeking(u64)

// More descriptive:
Seeking {
    target_position_ms: u64,
}

The enum now makes important invalid states unrepresentable:

  • A Playing state must contain a Track, a position, and buffer information.
  • An Idle state cannot accidentally contain a stale playback position.
  • A Failed state always records both the source and explanatory message.
  • There is no way to construct a single PlaybackState that is both Paused and Playing.

This is more than organization. The type itself carries part of the player’s correctness policy.

Combining the struct and enum

The outer struct holds data that applies across playback modes; the enum holds the mode-specific data:

let player = PlayerState {
    volume: 70,
    muted: false,
    playback: PlaybackState::Ready {
        track: Track {
            title: String::from("Opening Scene"),
            duration_ms: 240_000,
        },
        buffered_ms: 8_500,
    },
};

The hierarchy matters:

  • volume and muted belong on PlayerState because they are meaningful in every playback mode.
  • position_ms belongs only in Playing and Paused, where a current position is meaningful.
  • source belongs in Loading and Failed, where it identifies the resource being requested or that failed.

A type cannot express every rule by itself. For example, u8 permits a volume above 100, and a position_ms value might exceed a track’s duration. Those are value-level constraints that later code should validate. But the enum removes an entire class of structural mistakes before the program runs.


Pattern matching reads the state safely and completely

Once a value can have several shapes, ordinary field access is no longer enough. This would not compile:

// Not valid: `PlaybackState` does not always have `position_ms`.
let position = player.playback.position_ms;

position_ms exists only in some variants. Before accessing it, Rust requires you to establish which variant you have. A match expression does exactly that.

fn status_text(state: &PlayerState) -> String {
    match &state.playback {
        PlaybackState::Idle => {
            String::from("idle")
        }

        PlaybackState::Loading { source } => {
            format!("loading {source}")
        }

        PlaybackState::Ready {
            track,
            buffered_ms,
        } => {
            format!("ready: {} ({buffered_ms} ms buffered)", track.title)
        }

        PlaybackState::Playing {
            track,
            position_ms,
            buffered_ms,
        } => {
            format!(
                "playing {} at {position_ms} ms ({buffered_ms} ms buffered)",
                track.title
            )
        }

        PlaybackState::Paused {
            track,
            position_ms,
            ..
        } => {
            format!("paused: {} at {position_ms} ms", track.title)
        }

        PlaybackState::Ended { track } => {
            format!("ended: {}", track.title)
        }

        PlaybackState::Failed { source, message } => {
            format!("failed to load {source}: {message}")
        }
    }
}

The function can be called without taking ownership:

println!("{}", status_text(&player));

This deliberately uses a shared borrow:

match &state.playback {

Because we matched a reference, names bound by the patterns such as track, source, and message are borrowed views into state; they do not move the String or Track out of the player. That connects directly to the previous lesson: status_text is an inspection operation, so its signature uses &PlayerState.

Patterns bind values inside variants

This match arm:

PlaybackState::Playing {
    track,
    position_ms,
    buffered_ms,
} => {
    // ...
}

does two jobs:

  1. It checks that the value is a Playing variant.
  2. It binds the variant’s fields to local names: track, position_ms, and buffered_ms.

The paused arm uses ..:

PlaybackState::Paused {
    track,
    position_ms,
    ..
} => {
    // ...
}

Here .. means “ignore the other fields of this struct-like variant.” The function needs the track title and position but has no need for buffered_ms in this status message.

Use .. when omitted fields are genuinely irrelevant to the current operation. It is not a way to avoid understanding which playback state is being handled.

match is an expression

Every arm above evaluates to a String, so the entire match evaluates to a String and becomes the return value of status_text.

This is a common Rust pattern:

let is_active = match &state.playback {
    PlaybackState::Playing { .. } => true,
    PlaybackState::Loading { .. } => true,
    PlaybackState::Idle
    | PlaybackState::Ready { .. }
    | PlaybackState::Paused { .. }
    | PlaybackState::Ended { .. }
    | PlaybackState::Failed { .. } => false,
};

The | combines patterns that have identical behavior. It says that these alternatives all produce false, while Playing and Loading produce true.


Exhaustiveness is a design tool, not just a compiler rule

A match over an enum must cover every possible variant. If the Failed arm is removed from status_text, Rust reports a compile error rather than allowing a forgotten case to become a runtime bug.

That requirement is especially valuable as the player grows. Suppose you later add:

Seeking {
    track: Track,
    from_position_ms: u64,
    target_position_ms: u64,
}

Every match that explicitly handles PlaybackState will now identify itself to the compiler as incomplete. Each location becomes a deliberate design decision:

  • What should status text say while seeking?
  • Should rendering continue?
  • Should audio be temporarily muted?
  • Which commands are valid during seeking?

In stateful and asynchronous programs, this compiler-guided review is a major advantage. A newly introduced state cannot silently fall through code written before that state existed.

Be deliberate with wildcard patterns

A wildcard arm, _, can make a match exhaustive:

let is_terminal = match &player.playback {
    PlaybackState::Ended { .. } | PlaybackState::Failed { .. } => true,
    _ => false,
};

This is appropriate because every nonterminal playback state intentionally has the same answer: false.

But avoid a wildcard when variants should receive distinct behavior:

// Too broad for user-visible status:
match &player.playback {
    PlaybackState::Playing { .. } => "playing",
    _ => "not playing",
}

The code compiles, but if Seeking is added later, it silently becomes “not playing.” That may or may not be correct, and the compiler cannot force a review because _ already accepts everything.

A useful rule is:

  • Use explicit arms when each state deserves individual consideration.
  • Use _ only when all remaining states deliberately share the same behavior.

Option handles a smaller kind of state

Rust’s Option<T> is itself an enum with two variants: Some(T) and None. It is useful for a fact that is simply present or absent:

struct AudioSettings {
    selected_language: Option<String>,
}

But Option<Track> alone would not model a player lifecycle. It could say whether a track exists, but not whether that track is loading, ready, playing, paused, ended, or failed. Use Option for genuine optionality; use a domain enum when the alternatives have distinct meaning and behavior.


Ownership during state changes

Your enum variants can own data. In the model above, both Ready and Playing own a Track. This matters when transitioning between states.

If code matches a playback state by value, it takes ownership of that state and can move its contents into a new variant:

fn resume(playback: PlaybackState) -> PlaybackState {
    match playback {
        PlaybackState::Paused {
            track,
            position_ms,
            buffered_ms,
        } => PlaybackState::Playing {
            track,
            position_ms,
            buffered_ms,
        },

        other => other,
    }
}

When the input is Paused, the track and numeric fields move directly into Playing; no Track clone is required. The function owns playback, so this transfer is permitted.

By contrast, status_text matched &state.playback because it only needed to inspect the state. That distinction is worth preserving:

Operation Typical input What happens
Render a status line &PlayerState Borrow and inspect
Decide whether playback is active &PlaybackState Borrow and inspect
Transform one state into another PlaybackState Consume the old state and move its data into the new one
Update a state in place &mut PlayerState Mutate with exclusive access

The other => other arm in resume is intentional: all states other than Paused remain unchanged. In a real player, you may instead reject an invalid command or report why it cannot be performed. The next lesson’s Result type will provide a principled way to represent those recoverable operation failures.


Key takeaways

A robust state model separates two kinds of information:

  • Use a struct for facts that coexist, such as volume, mute state, track metadata, and buffer measurements.
  • Use an enum when the program is in exactly one of several mutually exclusive modes.
  • Put data directly in the enum variant where that data is only meaningful in that state.
  • Use match to identify a variant and bind its associated data safely.
  • Match on &value when you only need to inspect state; match by value when a transition should consume and reuse its owned data.
  • Let exhaustive matching force a decision for every playback mode, especially when new states are introduced.
  • Use _ only when every remaining case deliberately has identical behavior.

Next, you will model operations that can fail without crashing the whole program, using Result and the question-mark operator.

Can't find a good explanation? Sign up and we'll make it for you

Sign up