Syntactic musings on match expressions
— 2025-04-29

  1. introduction
  2. logical and
  3. logical or
  4. if let-evaluation ordering
  5. closing words

Introduction

One of the things that stands out to me is how similar match and if..else are semantically, while being diverging a fair bit syntactically. The reasons for that seem mostly accidental, and I have a sneaking suspicion we could make things a little easier by making both constructs look more similar.

In this post I'll be showing some control flow examples based on a queue that can either be online of offline. This is defined by an enum QueueState which stores the length of the queue as a u32:

enum QueueState {
    Online(u32),
    Offline(u32),
}

Depending on whether the queue is actively having new data added to it (online), we will want to reason differently about the queue's limits (u32). We also want to know change the behavior based on whether the queue is full or not, and in order to do that we will want to call a function:

fn is_full(n: u32) -> bool { .. }

Logical AND

Take the humble if-guard. We can use this in match arms to chain conditions, which is useful when we want to do something with a value contained within a structure. Here the is_full method returns a bool. But in order to call it we have first access the number contained in the Online variant:

match state {
    QueueState::Online(count) if !is_full(count) => {
    //                        ^^^^^^^^^^^^^^^^^^
    //                            if-guard
        println!("queue is online and not full");
    }
    _ => println!("queue is in an unknown state"),
}

if-guards are really just a logical AND. In match statements we're in the process of adding if let-chaining to Rust by way of RFC 2497 (stable in Rust 1.88). With that we'll be able to write this same match condition as follows:

if let QueueState::Online(count) = state && !is_full(count) {
//                                       ^^^^^^^^^^^^^^^^^^
//                                           && chaining
    println!("queue is online and not full");
} else {
    println!("queue is in an unknown state");
}

This is basically the same feature exposed two different ways. Instead I really would like to be able to use && in both cases, bringing the two closer together:

match state {
    QueueState::Online(count) && !is_full(count) => {
    //                        ^^^^^^^^^^^^^^^^^^
    //                            && chaining
        println!("queue is online and not full");
    }
    _ => println!("queue is in an unknown state"),
}

I can guarantee that just about everyone who has had exposure to a language which C-like notation (which rounds to all programmers) will have little trouble understanding how this works. if-guards, in comparison, tend to be harder to intuit.

Logical OR

RFC 3637 introduces the "guard patterns" feature: an extension to Rust's pattern notation which that moves if guards out of match expressions and into all patterns. What it functionally provides us with is a way to chain boolean AND expressions with logical OR expressions in all patterns. Say we took our previous example, and we wanted to print a message if is_full returned false, regardless of the whether the queue was online or offline. Using chained if..lets we would write this as follows:

if let QueueState::Online(count) = state && !is_full(count) {
    println!("queue is online and not full");
} else if let QueueState::Online(count) = state && is_full(count) {
    println!("queue is full"); // 1. This statement...
} else if let QueueState::Offline(count) = state && is_full(count) {
    println!("queue is full"); // 2. ...is duplicated here.
} else {
    println!("queue is in an unknown state");
}

This is a little annoying because ideally we'd want both the second and third condition to be one big condition, separated by a logical OR. Guard patterns are the feature which will allow us to do exactly that by bringing if-guards and pattern OR-based pattern chaining to expressions. With that we would rewrite this as follows:

if let QueueState::Online(count) if !is_full(count) = state {
    //                           ^^^^^^^^^^^^^^^^^^
    //                           now using an if-guard, for fun
    println!("queue is online and not full");
} else if let ((QueueState::Online(count) if is_full(count) = state) | // logical OR
    //        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    //                   Either matches on pattern 1...
    (QueueState::Offline(count) if is_full(count) = state)
    // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    //                   ...or on pattern 2.
{
    println!("queue is full"); // No more duplication!
} else {
    println!("queue is in an unknown state");
}

There's a lot going on here. For one, this makes if-guards legal in if..let expressions. And it also allows multiple patterns to be placed used in a single conditional using OR semantics, as long as they're appropriately parenthesized. Let's keep going on, and use this with a match expression:

match state {
    QueueState::Online(count) if !is_full(count) => {
        println!("queue is online and not full");
    }
    ((QueueState::Online(count) if is_full(count)) |
    (QueueState::Offline(count) if is_full(count)) => {
        println!("queue is full"); // No more duplication!
    }
    _ => println!("queue is in an unknown state"),
}

This is a lot better since the flow is clearer. By not having the explicit = state repeated on every line we can now actually read all the statements left-to-right. That's all good. We like that. Porting from the if..let version to the match version was also really easy. We didn't have to change the if..let conditions at all, aside from trimming their ends. That's good!

What's less good however is that we achieved this by making if..else expressions more involved. The interaction of RFC 2497 if..let chains and RFC 3637 guard patterns seems particularly like it might catch people off guard. Consider for example this line:

if let Foo::Bar(x) if cond(x) = bar() && let Bin::Baz = baz() { .. }

The order in which expressions are evaluated on this line is here is 2-3-1-5-4. That means it's probably not advisable to mix both features. The reason why these interactions are this way is because both RFCs operate from fundamentally opposing premises:

In the previous section we already looked at replacing if-guards with the && operator, inspired by if..let-chaining. It's not a far reach to imagine if..let-chaining using the || operator as well. That would look something like this:

if let QueueState::Online(count) = state && !is_full(count) {
    //                                   ^^^^^^^^^^^^^^^^^^
    //                                   if-let chain
    println!("queue is online and not full");
} else if let QueueState::Online(count) = state && is_full(count) ||
    //        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    //                   Either matches on pattern 1...
    let QueueState::Offline(count) = state && is_full(count)
    //  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    //                   ...or on pattern 2.
{
    println!("queue is full"); // No more duplication!
} else {
    println!("queue is in an unknown state");
}

It's the same functionality as before, but provided as a natural extension to &&-based if..let-chaining. Now once more, let's try rewriting this as a match expression:

match state {
    QueueState::Online(count) && !is_full(count) => {
        println!("queue is online and not full");
    }
    QueueState::Online(count) && is_full(count) || // <- logical OR!
    QueueState::Offline(count) && is_full(count) => {
        println!("queue is full");
    }
    _ => println!("queue is in an unknown state"),
}

What's neat here is that because logical AND (&&) takes precedence over logical OR (||) (ref), we don't need to resort to wrapping our patterns in order to chain them. Oh and I guess of course it's also nice that just about every programmer regardless of familiarity with Rust should be able to parse what is going on here.

But even more than that: look at this! This is code that I want to be writing. It's not trying to be clever or inventing anything particular new. It's defining feature is just how ordinary this looks. And that really agrees with my sensibilities for what a general-purpose programming language should feel like.

if let-evaluation ordering

I don't like reading if-let statements because they flip the read order from right-to-left. I double don't like reading if-let statements when chained because they need to be read center-left-right. I like the is-operator because it allows us to fix that. Let me show you how by taking our last if let..else example, with all comments stripped:

if let QueueState::Online(count) = state && !is_full(count) {
    println!("queue is online and not full");
} else if let QueueState::Online(count) = state && is_full(count) || 
    let QueueState::Offline(count) = state && is_full(count)
{
    println!("queue is full");
} else {
    println!("queue is in an unknown state");
}

However much I like the boolean operators here, I don't like the order in which statements evaluate. The first line in our example above is evaluated as follows:

if let QueueState::Online(cond) = state && !is_full(count) { .. }
//     ^^^^^^^^^^^^^^^^^^^^^^^^   ^^^^^    ^^^^^^^^^^^^^^^
//                |                 |              |
//                |       this is evaluated first  |
//                |                                |
//     this is evaluated second                    |
//                                                 |
//                                        this is evaluated last

RFC 3573 proposes to add the is-operator to the language, inspired by C#'s feature of the same name. This would fix the evaluation order, making the same code instead read from top-to-bottom, right-to-left:

if state is QueueState::Online(count) && !is_full(count) {
    println!("queue is online and not full");
} else if state is QueueState::Online(count) && is_full(count) || 
    state is QueueState::Offline(count) && is_full(count)
{
    println!("queue is full");
} else {
    println!("queue is in an unknown state");
}

To me this is Rust as it should be. The point has always been to make a language that is as practical as possible to use, without compromising on correctness, performance, and control. Making evaluation orders easier to follow seems like it's right in line with that goal. Here is that same line annotated, but using the is notation:

if state is QueueState::Online(cond) && !is_full(count) { .. }
// ^^^^^    ^^^^^^^^^^^^^^^^^^^^^^^^    ^^^^^^^^^^^^^^^
//   |                         |                |
//  this is evaluated first    |                |        
//                             |                |
//                  this is evaluated second    |
//                                              |
//                                    this is evaluated last

It also comes with an added benefit: it makes it so refactoring code from if..else form to match form produces an incredibly small diff. It's mostly the same code, but with the start of each condition trimmed:

match state {
    QueueState::Online(count) && !is_full(count) => {
        println!("queue is online and not full");
    }
    QueueState::Online(count) && is_full(count) ||
    QueueState::Offline(count) && is_full(count) => {
        println!("queue is full");
    }
    _ => println!("queue is in an unknown state"),
}

This would make it possible to explain conditional control flow more incrementally. I see the potential for carving out a little didactic staircase into Rust's famously steep learning curve that goes a little something like this:

  1. Start by introducing if..else, which most programmers will already know.
  2. Then introduce is as a more powerful version of typeof in other languages.
  3. Finally introduce match as a more ergonomic version of if..else + is. Like switch in other languages, but exhaustively checking all cases.

We never take any concepts away or have big changes in notation. Each concept neatly builds on the previous one, and we don't need to wait to introduce if-let until much later like we do now.

Closing Words

Another thing I wasn't quite sure when to talk about when it comes to the guard patterns RFC is its interaction with pattern types. Bringing if-guards into pattern feels like it changes the pattern notation from a fairly tractable/limited subset that we can evaluate in reasonable time into a system for arbitrary pre/post-conditions. Unless of course we disallow those kinds of patterns in pattern types - which creates diverging pattern notations.

Overall I feel like we have a lot to gain in the language by making match-expressions feel more consistent with the rest of the conditional expressions. After writing the bulk of this post, I remembered that what I'm sharing here is actually not a unique insight. RFC 3796 cfg_logical_ops by Jacob Pratt proposes the addition of the boolean &&, ||, and ! operators in cfg attributes. Effectively replacing the existing domain-specific all(), any(), and not() operators.

// current
#[cfg(all(any(a, b), any(c, d), not(e)))]
struct MyStruct {}

// proposed
#[cfg((a || b) && (c || d) && !e)]
struct MyStruct {}

I like what that RFC proposes. I doubly like it when we consider it as part of a broader effort to reduce the amount of micro-syntaxes spread throughout the language. In turn making Rust an easier language, by virtue of it being smaller and more consistent.