Enums and Pattern Matching

On this page

An enum represents one of several possible shapes, where each variant can carry different data. Compared to a single state number plus a set of nullable fields, it allows "which fields exist in this state" to be expressed directly in the type.

DisconnectedNo connection parametersConnectingaddress: StringConnectedpeer: String, session: u64Put data for different states into their corresponding variantsEach value is in exactly one variant; the type excludes meaningless field combinations, but state transition rules still need to be implemented.

Defining State and Data Together

enum Connection {
    Disconnected,
    Connecting { address: String },
    Connected { peer: String, session: u64 },
}
fn describe(state: &Connection) -> String {
    match state {
        Connection::Disconnected => "offline".to_owned(),
        Connection::Connecting { address } => format!("connecting to {address}"),
        Connection::Connected { peer, session } => format!("{peer}, session {session}"),
    }
}
fn main() {
    let states = [
        Connection::Disconnected,
        Connection::Connecting { address: "localhost".into() },
        Connection::Connected { peer: "localhost".into(), session: 7 },
    ];
    assert_eq!(describe(&states[0]), "offline");
    assert!(describe(&states[1]).contains("connecting"));
    assert!(describe(&states[2]).contains("session 7"));
}

Now Disconnected won't accidentally carry an uncleaned session field. However, the enum itself does not prevent the program from jumping from any state to another; to restrict transitions, you still need to encapsulate fields, methods, and construction entry points.

"Tagged unions" are a useful semantic model, but they do not guarantee that the physical storage has an independent tag. The actual size is also affected by the maximum payload, alignment, and niche optimization, so you cannot simply add them up mechanically, nor can you ignore the size of owning types like String.

Derive Types from Field Combinations Instead of Writing a Set of Options First

If you express three states using status: u8, address: Option<String>, and session: Option<u64>, field combinations could still result in scenarios like "connected but no session" or "offline but residual session data". An enum directly collects the allowed data for each shape into its variants, reducing the need for callers to repeatedly check conditions.

This is often referred to as a sum type: a value is in one of several candidates. A struct groups fields together, often called a product type. In simple examples of finite sets, two bools have 2×2=4 combinations; a Pending | Finished(bool) has 1+2=3 shapes. Here we are counting semantic values, not layout bytes.

Modeling ChoiceSuitable ForAdditional Constraints
OptionPresent or absentAbsence reasons are usually not distinguished
Result<T, E>Successful result or reportable errorFailure categories are designed by E
Custom enumDifferent payloads per stateTransitions, resource ownership, and invariants
Multiple independent fieldsConditions that can truly be combined independentlyDo not artificially prohibit valid combinations

Which combinations should never exist?

The table counts payload presence: three states times four combinations of two optional fields gives 12 shapes, of which only three are needed here. Select a combination to see how the enum excludes the others. These counts are neither object sizes nor counts including every possible string.

Preparing the visual
Which combinations should never exist?

Separate fields create 12 shapes; this enum permits only three payload shapes.

State Transitions: Consuming the Old Value and Clarifying Ownership on Failure

DrafttextQueuedtext + ticketDonereceiptSubmitCompleteState shapes are expressed by enums, allowed transitions are checked by functionsText is non-empty; the ticket for the completion event must match the current taskInvalid transitions return the original state and reason; valid field combinations do not imply that arbitrary state jumps are legal.

The following conversion function takes Job by value. On success, the old state is consumed, and data moves into the new state; on failure, it returns the original state and an error, allowing the caller to continue editing or logging without having to clone the String for the failure path. The completion event also carries a ticket, preventing completion events for old tasks from incorrectly advancing the current task.

#[derive(Debug, PartialEq)]
enum Job {
    Draft { text: String },
    Queued { text: String, ticket: u64 },
    Done { receipt: String },
}
enum Event {
    Submit { ticket: u64 },
    Complete { ticket: u64, receipt: String },
}
fn advance(job: Job, event: Event) -> Result<Job, (Job, &'static str)> {
    match (job, event) {
        (Job::Draft { text }, Event::Submit { ticket }) if !text.trim().is_empty() => {
            Ok(Job::Queued { text, ticket })
        }
        (Job::Queued { ticket, .. }, Event::Complete { ticket: received, receipt })
            if ticket == received => Ok(Job::Done { receipt }),
        (previous, _) => Err((previous, "invalid transition")),
    }
}
fn main() {
    let draft = Job::Draft { text: "report".into() };
    let queued = advance(draft, Event::Submit { ticket: 7 }).unwrap();
    let (queued, reason) = advance(queued, Event::Complete {
        ticket: 6, receipt: "stale".into(),
    }).unwrap_err();
    assert_eq!(reason, "invalid transition");
    assert!(matches!(&queued, Job::Queued { ticket: 7, .. }));
    let done = advance(queued, Event::Complete {
        ticket: 7, receipt: "stored".into(),
    }).unwrap();
    assert_eq!(done, Job::Done { receipt: "stored".into() });
    let empty = Job::Draft { text: String::new() };
    assert!(advance(empty, Event::Submit { ticket: 8 }).is_err());
}

The guard here checks the condition when deciding whether to select a branch; if the condition fails, it can still fall through to subsequent matching branches. When completion is successful, text is no longer needed, so the old text is released along with the consumed state; if the business logic still requires auditing the full text, it should be saved in the new state or an external record, and you cannot expect the enum to automatically retain it.

This function guarantees the listed local transition rules, but it does not execute external tasks, verify the global uniqueness of the ticket, or persist the result. If Job and its variant constructors are all public, other code can still bypass the function and construct Done directly; enforcing entry points externally requires module encapsulation. If you want to prevent calling completion operations on Draft at compile time, you can further use different types of typestate interfaces, at the cost of more complex runtime state sets and heterogeneous container handling.

Exhaustive Checking of Possible Shapes in match

An exhaustive match without a fallback will prompt you to add branches after new variants are added. A match that already uses _ can continue to compile, so you cannot say that all match sites will error due to new variants. Public non_exhaustive enums also require downstream code to reserve matching ways for future extensions.

match searches for matching branches in source code order. A guard is a boolean condition checked additionally after a pattern succeeds; the compiler generally does not treat the logic of arbitrary guards as a proof of complete coverage. match

fn classify(input: Option<i32>) -> &'static str {
    match input {
        Some(value @ 1..=5) if value % 2 == 1 => "small odd",
        Some(_) => "other number",
        None => "missing",
    }
}
fn main() {
    assert_eq!(classify(Some(3)), "small odd");
    assert_eq!(classify(Some(2)), "other number");
    assert_eq!(classify(None), "missing");
}

@ simultaneously saves the matched value and checks the sub-pattern. if let is suitable for processing only one shape, while while let is suitable for repeatedly fetching the same type of result; ignoring the rest should be the intent of the interface, and you cannot treat syntactic brevity as proof that error handling is complete.

Exhaustiveness is a Proof of Coverage, Not a Proof of Business Correctness

Some(x) if x > 0 and Some(x) if x <= 0 look like they cover all i32s, but the compiler does not universally prove the complementary relationship of arbitrary guards, and corresponding guard-less covering branches are still required. A _ => {} will allow compilation to pass, but it may silently ignore newly added important states.

Each branch of an or-pattern must bind the same name, compatible types, and binding patterns, e.g., Some(1 | 2) only tests the value, while Ok(value) | Err(value) also requires the value types on both sides to be consistent. @ binds the value while checking the sub-pattern, rather than copying an arbitrarily large data instance.

fn payload_len(value: Result<&str, &str>) -> usize {
    match value {
        Ok(text) | Err(text) => text.len(),
    }
}
fn command(input: &[u8]) -> Option<(&[u8], u8)> {
    match input {
        [0x7e, payload @ .., checksum] => Some((payload, *checksum)),
        _ => None,
    }
}
fn main() {
    assert_eq!(payload_len(Err("bad")), 3);
    assert_eq!(command(&[0x7e, 1, 2, 3]), Some((&[1, 2][..], 3)));
    assert_eq!(command(&[0x7e, 3]), Some((&[][..], 3)));
    assert_eq!(command(&[0x7e]), None);
}

The slice pattern example only checks the shape and start marker, and does not actually verify the checksum. Patterns are good at describing structure, but business relationships still need to be calculated explicitly. Ordinary let requires non-refutable patterns; Some(x), which may not match, is better suited for match, if let, or let-else.

Pattern Bindings May Move or Borrow

fn main() {
    let item = Some(String::from("hello"));
    match &item {
        Some(text) => assert_eq!(text.len(), 5), // text: &String
        None => unreachable!(),
    }
    let Some(text) = item else { return; }; // Move String here
    assert_eq!(text, "hello");
}

When matching against &item, the match ergonomics rules cause the inner binding to borrow the field; destructuring item by value for non-Copy fields transfers ownership. let-else requires the else branch to diverge, e.g., return, break, or panic, so that subsequent paths can rely on the pattern having succeeded.

_ in a pattern does not create a binding; _name still creates a variable, which can move and own the value. Rust 2024 has stricter rules for explicitly writing ref/mut combinations in some implicit borrowing patterns; clearly choosing to match &value vs value is usually more readable. Patterns

Look at the Matched Expression and Binding Type Together

Input and SyntaxType/Behavior of textOriginal Container Retained?
match item, Some(text)String moved by valueThe moved part can no longer be used as usual
match &item, Some(text)&StringRetained, borrow rules apply during the borrow
match &mut item, Some(text)&mut StringRetained, allows modifying fields via the borrow
match item.as_deref()&str inside Option<&str>Retained, does not transfer string ownership
item.take()Takes the original value of the OptionThe original position becomes None

mut text allows the binding to be reassigned, which is not equivalent to "borrowing a &mut String"; ref mut text is what requests a mutable borrow in the corresponding by-value pattern context. Rust 2024 restricts certain combinations of writing ref/mut in default borrowing modes, prioritizing explicit selection of matching &item or &mut item to make the input hierarchy clear. Rust 2024 Match Rules

fn main() {
    let mut item = Some(String::from("hello"));
    if let Some(text) = &mut item {
        text.push('!');
    }
    assert_eq!(item.as_deref(), Some("hello!"));
    let taken = item.take().unwrap();
    assert_eq!(taken, "hello!");
    assert!(item.is_none());

    let ignored = Some(String::from("still here"));
    if let Some(_) = ignored {
        assert_eq!(ignored.as_deref(), Some("still here"));
    }
    // Some(_) does not bind and move the String; Some(_text) would bind that field.
    assert!(ignored.is_some());
}

You cannot directly move the String out of &mut Option<String> and leave an uninitialized hole; take() solves this by placing a valid None in its place. For other types, you can look into mem::replace, but the replacement value should be a state allowed by the business logic, not a meaningless state invented just to bypass borrow errors. Option::take

Partial moves also require distinguishing between "whether the entire value can still be used" and "whether the un-moved fields can still be used". After moving a String field from a struct without a custom Drop, other un-moved fields may still be accessible; borrowing the entire struct or calling methods that depend on the complete value is different. Types with custom Drop also have restrictions on directly moving out their fields.

Option and Result are Common Enum Protocols

Ordinary references cannot be null, but Rust's raw pointers allow null values, so "Rust has no null" is an overgeneralization. Option allows normal safe interfaces to express absence, and callers can still choose unwrap and panic on None; the type does not automatically choose a recovery plan for them.

fn safe_div(a: i32, b: i32) -> Option<i32> { a.checked_div(b) }
fn main() {
    assert_eq!(safe_div(10, 2), Some(5));
    assert_eq!(safe_div(10, 0), None);
    assert_eq!(safe_div(i32::MIN, -1), None);
}

Only checking b == 0 misses the overflow of dividing the minimum signed integer by -1. If the caller needs to distinguish between the two types of failure, they should return a Result with categories, rather than compressing all reasons into None.

Result incorporates reportable failures into the function's return type, but it does not guarantee the absence of internal panics or external side effects. See Result and Option for error propagation and composition methods.

Evolution of Public Enums and Wire Decoding

#[non_exhaustive] mainly constrains usage outside the defining crate; the crate itself can still fully match its currently defined variants. Downstream code needs to reserve branches for unknown variants, but this is not permission to turn arbitrary integers into valid enums at runtime. non_exhaustive

If wire status codes allow for future additions, you can save unknown numeric values as a distinct Unknown(u8) variant. This is completely different from an illegal discriminant in a Rust enum: Unknown is a defined, valid variant that carries the raw code for logging, forwarding, or subsequent processing.

#[derive(Debug, PartialEq)]
enum Status { Ready, Busy, Unknown(u8) }
impl From<u8> for Status {
    fn from(code: u8) -> Self {
        match code {
            1 => Self::Ready,
            2 => Self::Busy,
            other => Self::Unknown(other),
        }
    }
}
fn main() {
    assert_eq!(Status::from(1), Status::Ready);
    assert_eq!(Status::from(2), Status::Busy);
    assert_eq!(Status::from(255), Status::Unknown(255));
}

This interface choice retains unknown codes, but operations that require understanding to execute should still be rejected. For inputs that do not allow unknown values, implement TryFrom and return an error. Do not use transmute to interpret arbitrary bytes as a finite variant enum; having the same layout size does not prove the discriminant is valid.

The combination of enums and match brings shape checking, field access, and ownership transfer together. When using them, confirm in order: which states require different data; which transitions are allowed; whether branches explicitly cover important cases; whether bindings are moving or borrowing; and how unknown inputs enter valid types. This makes it easy to quickly locate syntax while keeping the model consistent with business behavior.