Why Did Rust Pick the 'If Let' Syntax?

One of my favourite features of Rust is that it has excellent support for pattern matching. This is usually done through match expressions, but there is also a shorter syntax that can be used if you only need a single branch, which is called if let.

If you're unfamiliar, a match expression looks like this:

let x = Some(123);

match x {
    Some(y) => {           // This matches if `x` is `Some`.
        println!("{}", y); // This prints '123'.
    }

    _ => {}                // This matches all other values.
}

And the equivalent if let expression looks like this:

let x = Some(123);

if let Some(y) = x {   // This matches if `x` is `Some`.
    println!("{}", y); // This prints '123'.
}

This shortcut syntax, useful as it is, can be quite confusing to new Rust developers. I commonly see people asking things like:

  • Why is that if statement 'backwards'?
  • Why is there a let in that conditional?
  • Why do I have to use = instead of ==?

I had similar questions when I started learning Rust, and to answer them, we need to take a step back and look at the plain old let syntax. It's relevant, I promise!

In most cases, you see let being used to declare a simple named variable, like this:

let x = 123;

What might not be apparent at first glance is that the left side of a let isn't just a name - it's a pattern, the same as what you'd use in the arm of a match expression!

This means that we can actually do pattern matching when declaring variables, like so:

enum Example {
    Data(i32),
}

let x = Example::Data(123); // Wrap the data.
let Example::Data(y) = x;   // Unwrap ('destructure') the data via a pattern.

println!("{}", y);          // This prints '123'.

There is, however, one important restriction here compared to when you're using match - in a let statement, you can only use patterns that are 'irrefutable'. In simpler terms, this means that you can only use a pattern which will match for every possible value of the type that you're matching against.

We can see what happens when this isn't the case by adding an extra variant to our Example enum:

enum Example {
    Data(i32),
    Oops,
}
    
let x = Example::Data(123);
let Example::Data(y) = x;

println!("{}", y);

This gives us an error which looks like this:

error[E0005]: refutable pattern in local binding: `Oops` not covered
 --> src/main.rs:8:9
  |
2 | /     enum Example {
3 | |         Data(i32),
4 | |         Oops,
  | |         ---- not covered
5 | |     }
  | |_____- `Example` defined here
...
8 |       let Example::Data(y) = x;
  |           ^^^^^^^^^^^^^^^^ pattern `Oops` not covered
  |
  = note: `let` bindings require an "irrefutable pattern", like a `struct` or an `enum` with only one variant
  = note: for more information, visit https://doc.rust-lang.org/book/ch18-02-refutability.html

So if we can't use let for this pattern, what can we use? This is where we circle back to if let!

enum Example {
    Data(i32),
    Oops,
}
    
let x = Example::Data(123);
if let Example::Data(y) = x {
    println!("{}", y);
}

Notice that this example is almost identical to the previous one that didn't compile - the only changes are the extra if, and the extra block to mark which code should only be run if the pattern matched.

This is the reason that if let looks the way it does - it's an extension of the existing let syntax (which only supports irrefutable patterns) to support all kinds of patterns!

Once I started thinking about it this way, the syntax started to feel a lot more natural, and I hope this post helps make the relationship between let and if let click for other people too.