Skip to main contentmatch and Pattern Matching in Depth | IoT Worker

match and Pattern Matching in Depth

C programmers often first read match as a stricter switch.

That comparison helps at the beginning, but it is incomplete. C switch mainly branches on integer values. If the state also carries data, you still manually read fields from a struct or union.

Rust match performs pattern matching:

check what shape a value has, and bind the data inside that shape.

Earlier, when discussing enum, we used Rust enums as a safer replacement for C tagged unions. This article continues from there: how match handles richer state, references, conditions, and local unpacking.

match Branches and Unpacks

C tagged unions usually check the tag, then read the payload:

switch (token->kind) {
case TOKEN_INT:
    printf("%d\n", token->data.int_value);
    break;
case TOKEN_IDENT:
    printf("%s\n", token->data.ident);
    break;
}

In Rust, branching and unpacking happen in the same pattern:

enum Token {
    Int(i32),
    Ident(String),
    End,
}

fn describe(token: &Token) {
    match token {
        Token::Int(value) => println!("int {value}"),
        Token::Ident(name) => println!("ident {name}"),
        Token::End => println!("end"),
    }
}

Token::Int(value) does not only test whether the variant is Int. It also binds the inner i32 to value.

The useful model is:

If the value has this shape, enter this branch and bind the variables inside it.

Structs and Tuples Can Be Unpacked Too

Patterns are not limited to enums. Struct fields can be matched:

struct Header {
    version: u8,
    flags: u8,
    len: u16,
}

fn print_header(header: Header) {
    match header {
        Header { version, flags, len } => {
            println!("v={version} flags={flags} len={len}");
        }
    }
}

If only some fields matter, use .. to ignore the rest:

fn is_ack(header: &Header) -> bool {
    match header {
        Header { flags, .. } => (*flags & 0x01) != 0,
    }
}

Tuples work the same way:

let point = (10, 20);

match point {
    (0, y) => println!("on y axis: {y}"),
    (x, 0) => println!("on x axis: {x}"),
    (x, y) => println!("point {x}, {y}"),
}

In C this is usually if plus field access. Rust patterns combine the shape check and field extraction.

Matching References: Borrow or Move

For enums that own data, matching by value may move the data out:

enum Message {
    Text(String),
    Ping,
}

fn consume(msg: Message) {
    match msg {
        Message::Text(text) => println!("{text}"),
        Message::Ping => println!("ping"),
    }
}

Here text owns the String. That is fine because consume already receives the Message by value.

If the function only inspects the message, match a reference:

fn inspect(msg: &Message) {
    match msg {
        Message::Text(text) => println!("{text}"),
        Message::Ping => println!("ping"),
    }
}

Here text is a &String, because msg is a &Message. In common cases, the compiler handles reference patterns without requiring ref everywhere.

For a C programmer, the first model can be:

match value  -> may move data out of value
match &value -> only borrows and inspects

When writing library APIs, if a function only reads state, it should usually take a reference.

if let Handles One Interesting Branch

If only one variant matters, a full match can feel heavy:

match token {
    Token::Ident(name) => println!("{name}"),
    _ => {}
}

Rust provides if let:

if let Token::Ident(name) = token {
    println!("{name}");
}

It means: if token matches the pattern Token::Ident(name), run the block and bind name.

This is common with Option:

if let Some(index) = find_index(items, target) {
    println!("found at {index}");
}

This is clearer than using a special -1 value in C:

int index = find_index(items, len, target);
if (index >= 0) {
    printf("%d\n", index);
}

If both branches matter, use match. If only one successful shape matters, use if let.

while let Repeats While a Pattern Matches

C often uses loops that keep fetching until no value remains:

while (next_token(&token)) {
    handle(token);
}

If a Rust API returns Option<T>, use while let:

while let Some(token) = lexer.next_token() {
    handle(token);
}

This means: continue the loop while the returned value matches Some(token); stop on None.

The same applies to Vec::pop:

let mut stack = vec![1, 2, 3];

while let Some(value) = stack.pop() {
    println!("{value}");
}

This follows the data shape more directly than a manual loop and break.

Match Guards Add Conditions

Sometimes a pattern is not enough; a branch also needs a condition. Rust uses match guards:

match token {
    Token::Int(value) if *value > 0 => println!("positive {value}"),
    Token::Int(value) => println!("non-positive {value}"),
    Token::Ident(name) if name.len() > 16 => println!("long ident"),
    Token::Ident(name) => println!("ident {name}"),
    Token::End => println!("end"),
}

if *value > 0 is an extra condition on that branch. The branch is used only when the pattern matches and the condition is true.

Guards are useful when the same shape needs additional classification. If the condition becomes complex, compute a named intermediate value or move the logic into a function.

_ Can Hide Future Branches

_ means “all other cases”:

match token {
    Token::Int(value) => println!("{value}"),
    _ => println!("other"),
}

It is convenient, but be careful in protocols, state machines, and command handlers. _ prevents newly added variants from triggering compiler errors.

If each state has engineering meaning, prefer listing them:

match token {
    Token::Int(value) => println!("{value}"),
    Token::Ident(name) => println!("{name}"),
    Token::End => println!("end"),
}

When Token::Error is added later, every location that needs to handle it will fail to compile. That is one of the main advantages of match over switch.

If you really want to ignore some data, make the ignored part narrower:

match token {
    Token::Int(value) => println!("{value}"),
    Token::Ident(_) => println!("identifier"),
    Token::End => {}
}

This is less likely to hide future changes than one broad _.

matches! Is for Simple Tests

If all you need is a boolean, use matches!:

let is_end = matches!(token, Token::End);

It also supports guards:

let is_large = matches!(token, Token::Int(value) if value > 1024);

This is good for simple tag-style checks. Do not cram complex business logic into matches!; when several branches need unpacking and handling, match is clearer.

What to Keep

match is not just upgraded C switch. It is pattern matching:

branch by the shape of a value
unpack data in the branch
let the compiler check exhaustiveness
use if let / while let for one-branch and repeated extraction
use guards for extra conditions
be careful with _

When moving from C to Rust, do not only translate switch into match. The bigger improvement is moving from “tag + payload + manual field reads” to “enum + pattern matching,” so the relationship between state and data is checked by the type system.

The next topic is iterators. C code often traverses data by index or pointer movement; Rust turns many traversal patterns into composable value streams.