Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 3

Modeling States with Rust Enums and Pattern Matching

Recommended resources

Hello! In our last lesson, we explored Rust's borrowing and reference system, discovering how it provides safe, flexible access to data without the overhead of constantly transferring ownership. This gave us the tools to manage how different parts of a program interact with data.

Today, we'll shift our focus to the data itself. We'll examine how to structure data to represent the different states a system can be in. This is the cornerstone of robust software, especially in embedded systems where a device's entire behavior is dictated by its current state (e.g., initializing, running, sleeping, error). Our learning outcome is to use Rust's algebraic data types (enums) and exhaustive pattern matching to model domain states. By the end of this lesson, you'll understand why Rust's approach is celebrated for its ability to "make invalid states unrepresentable," a concept that will powerfully resonate with your experience in designing complex systems.

Beyond Booleans and Integers

In your experience with C/C++ and other languages, you've likely represented state using simple types. For example, a network connection might be tracked with bool is_connected;, or a device mode might be uint8_t mode; where 0 means "off", 1 means "on", and 2 means "standby".

While this works, it has significant drawbacks that you've probably encountered:

  • It's not self-documenting: What does mode = 2 mean without looking up a comment or a constant definition?
  • It allows invalid values: What happens if mode is accidentally set to 5? The system is now in an undefined state.
  • It's hard to extend: Adding a new "error" state requires finding all the places that check the mode variable and ensuring they are updated correctly.

Rust provides a far more powerful and safer tool for this job: the enum.

Using Enums to Represent State | corrode Rust Consulting

This article from Corrode Rust Consulting directly addresses the limitations of using primitive types for state and introduces enum as the idiomatic Rust solution.

Please read the first part of the article, starting from the top and continuing down to just before the section titled Use Enums Instead!. Pay attention to the critique of using bool and u8 and the initial attempt to improve it with a newtype pattern. This sets the stage for why enums are a superior choice.


Algebraic Data Types: More Than Just Numbers

In C, an enum is little more than a set of named integer constants. In Rust, enums are algebraic data types, specifically "sum types" or "tagged unions". This means a value of an enum can be one of several different variants, and each variant can optionally hold its own unique data.

This is a profound difference. A C union allows different data types to share the same memory location, but it's your responsibility to track which type is currently active—a common source of bugs. Rust's enum bundles a "tag" with the data, making access type-safe.

Programming in Rust: Enums, Tagged Unions, Memory Layout and Pattern Matching

This video provides an excellent technical overview of Rust enums, contrasting them with C unions and visualizing their memory layout.

Watch the following segments: Introduction to Enums: This covers the basic definition and the concept of variants storing data. Comparison with C Unions and Memory Layout: This is a key section. It shows the danger of C unions and explains how Rust's "tagged union" approach is safer. The memory layout visualization is particularly relevant for embedded programming.

As the video explains, a Rust enum can be as simple as a list of states:

enum TrafficLight {
    Red,
    Yellow,
    Green,
}

Or it can be much richer, with variants that hold data. Notice how each variant can have a different data structure. This is impossible with C enums.

enum Message {
    Quit, // No data
    ChangeColor(u8, u8, u8), // A tuple of RGB values
    Move { x: i32, y: i32 }, // A struct with named fields
    Write(String), // A single String
}

This ability to associate specific data with specific states is what allows us to start building models where invalid states are simply unrepresentable. For example, a Suspended state can hold the time it is suspended until, while an Active state needs no such data.

This diagram reflects a system similar to those you've built. At its heart is the `enum JobState`, which perfectly demonstrates this principle. States like `Queued`, `Processing`, and `Failed` each carry different data relevant only to that specific state, all modeled within a single, type-safe enum.

The match Expression: Exhaustive Pattern Matching

So, we have this powerful enum to define states. How do we act on them? The primary tool is the match expression. It's like a switch statement on steroids. match compares a value against a series of patterns and executes the code for the first matching pattern.

Critically, match is exhaustive. The Rust compiler guarantees that you have handled every single possible variant of your enum. If you add a new variant to your enum, your code will not compile until you update every match statement to handle that new case. This eliminates a huge class of bugs related to unhandled states.

The match Control Flow Construct

The official Rust book provides the definitive introduction to match. It will walk you through the syntax, power, and safety guarantees of this core language feature.

Please read the following sections from the chapter: Start at The match Control Flow Construct, which introduces the concept with a coin-sorting analogy. Continue through Patterns That Bind to Values to see how you can extract data from enum variants within a match arm. Read Matches Are Exhaustive. This is the most crucial part, as it explains the compile-time safety you get. Finally, read Catch-All Patterns to learn how to handle multiple cases at once.

Here's an example combining an enum with a match expression:

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
}

fn process_message(msg: Message) {
    match msg {
        Message::Quit => {
            println!("The Quit variant has no data.");
        }
        Message::Move { x, y } => {
            println!("Move to coordinates x: {}, y: {}", x, y);
        }
        Message::Write(text) => {
            println!("Text message: {}", text);
        }
    }
}

Notice how we can bind the inner values of the variants (x, y, text) to variables that are only available within that match arm.


Application: Building a State Machine

With enum and match, we have the perfect tools to implement a Finite State Machine (FSM). This is a model where a system can only be in one of a finite number of states at any given time, and can only transition between states based on defined events. This model applies to almost every embedded device you can think of.

This is a diagram of a finite state machine for parsing a domain name. The circles are states, and the arrows are transitions triggered by specific conditions. We can model this exact structure in Rust with enums and match.

Let's see how to do it. The core idea is:

  1. Define all possible states with an enum.
  2. Define all possible events or transitions with another enum.
  3. Create a struct that holds the current state.
  4. Implement a method on that struct that takes an event and uses a match expression to enforce the transition logic.

Rust Data Modelling Without Classes

This video from "No Boilerplate" brilliantly articulates the philosophy of "making invalid states unrepresentable" and demonstrates it by building a state machine based on Super Mario World.

Please watch these parts: Data Modeling Introduction: This section introduces algebraic data types and the power of match to enforce correctness at compile time. State Machine Implementation: This is the main event. It shows, step-by-step, how to use enums and a match on a tuple (state, transition) to build a state machine where the compiler guarantees all transitions are handled.

The pattern shown in the video, matching on a tuple of (current_state, event), is incredibly powerful. The compiler doesn't just check that you handled all states; it checks that you've handled every possible combination of state and event, forcing you to explicitly define what happens in every scenario. This moves your system's logic from a series of fragile if/else statements into a single, verifiable match block that is guaranteed by the compiler to be complete.


Conclusion

In this lesson, we've seen how Rust elevates the concept of state management from a bug-prone convention to a core, compiler-enforced language feature. Your experience designing large-scale systems gives you a unique appreciation for the cost of unhandled states and logic errors. Rust's approach tackles this problem head-on at compile time.

Key takeaways from today:

  • Enums for State: Use Rust enums to model states instead of bools or integers for type safety and clarity.
  • Variants with Data: Rust enums are "fat," allowing different states to carry different associated data, enabling you to "make invalid states unrepresentable."
  • Exhaustive match: The match keyword is the primary way to interact with enums. The compiler enforces that you handle every possible case, preventing bugs from forgotten states.
  • State Machine Pattern: Combining enums for states/events with a match expression provides a robust, compile-time-verified way to build state machines, a fundamental pattern in embedded programming.

We now have a solid foundation for defining and managing data and state. In our next lesson, we will explore traits and generics. These features will allow us to define shared behaviors and write reusable, flexible code that operates on different data types, further enhancing our ability to build complex, maintainable systems in Rust.