Logo
Logo

Atharva Pandey/Lesson 8: Refutable vs Irrefutable Patterns — Where they apply

Created Thu, 08 Aug 2024 12:40:00 +0000 Modified Thu, 08 Aug 2024 12:40:00 +0000

I remember the first time the Rust compiler told me “refutable pattern in local binding.” I stared at the error for five minutes, Googled “refutable pattern Rust,” read the explanation, and thought: “That’s… actually a really good distinction that no other language makes explicit.”

Every pattern in Rust is either refutable or irrefutable. Understanding which is which — and where each is allowed — clears up a whole class of confusing compiler errors.

The Distinction

Irrefutable patterns always match. They cannot fail. Examples: let x = 5, let (a, b) = (1, 2), let Point { x, y } = point. The pattern will always succeed for any value of the right type.

Refutable patterns might not match. They can fail. Examples: Some(x) (fails on None), 1..=10 (fails on 11), Variant::A { .. } when the value could be Variant::B. The pattern only succeeds for some values.

Rust enforces where each kind can appear:

  • let bindings require irrefutable patterns
  • Function parameters require irrefutable patterns
  • if let accepts refutable patterns
  • while let accepts refutable patterns
  • match arms accept refutable patterns
  • for loops require irrefutable patterns

Break these rules and the compiler tells you — clearly.

Why the Compiler Cares

// This won't compile:
// let Some(x) = some_option;
// Because: what would `x` be if `some_option` is None?

// This compiles fine:
// let (a, b) = (1, 2);
// Because: a tuple of two elements always destructures into two elements.

The compiler isn’t being pedantic. If you write let Some(x) = value and value is None, the program has no valid way to continue. There’s no x to bind. In other languages, this would be a runtime crash or a null pointer. Rust catches it at compile time.

fn main() {
    let pair = (42, "hello");

    // Irrefutable: always matches
    let (number, text) = pair;
    println!("{}: {}", number, text);

    let maybe_value: Option<i32> = Some(10);

    // Refutable: needs if let because it might be None
    if let Some(v) = maybe_value {
        println!("Got: {}", v);
    }

    // This would NOT compile:
    // let Some(v) = maybe_value;
    // Error: refutable pattern in local binding
}

if let: The Right Tool for Refutable Patterns

if let exists specifically for “match on one pattern and ignore the rest”:

#[derive(Debug)]
enum Config {
    File(String),
    Env(String),
    Default,
}

fn load_config(source: &Config) -> String {
    if let Config::File(path) = source {
        println!("Loading from file: {}", path);
        return format!("file:{}", path);
    }

    if let Config::Env(var) = source {
        println!("Loading from env: {}", var);
        return format!("env:{}", var);
    }

    println!("Using default config");
    "default".to_string()
}

fn main() {
    let configs = vec![
        Config::File("/etc/app.conf".to_string()),
        Config::Env("APP_CONFIG".to_string()),
        Config::Default,
    ];

    for c in &configs {
        println!("Result: {}\n", load_config(c));
    }
}

Is this better than match? Sometimes. When you only care about one or two variants and want early returns, if let reads more naturally than a match with a _ => {} arm. But if you’re handling three or more variants, use match — it’s more readable and gives you exhaustiveness checking.

if let With else

if let can have an else branch for the non-matching case:

fn parse_port(input: &str) -> u16 {
    if let Ok(port) = input.parse::<u16>() {
        port
    } else {
        eprintln!("Invalid port '{}', using default 8080", input);
        8080
    }
}

fn main() {
    println!("Port: {}", parse_port("3000"));
    println!("Port: {}", parse_port("not_a_number"));
    println!("Port: {}", parse_port("99999")); // overflow, parse fails
}

let else: The Inverse Pattern

Stabilized in Rust 1.65, let else is for when you want to bind on success and diverge (return, break, continue, panic) on failure. It’s the inverse of if let:

#[derive(Debug)]
struct User {
    name: String,
    email: Option<String>,
    age: Option<u32>,
}

fn send_notification(user: &User) -> Result<(), String> {
    let Some(email) = &user.email else {
        return Err(format!("No email for user {}", user.name));
    };

    let Some(age) = user.age else {
        return Err(format!("No age for user {}", user.name));
    };

    if age < 18 {
        return Err(format!("{} is under 18", user.name));
    }

    println!("Sending notification to {} at {}", user.name, email);
    Ok(())
}

fn main() {
    let users = vec![
        User {
            name: "Alice".to_string(),
            email: Some("alice@example.com".to_string()),
            age: Some(25),
        },
        User {
            name: "Bob".to_string(),
            email: None,
            age: Some(30),
        },
        User {
            name: "Charlie".to_string(),
            email: Some("charlie@example.com".to_string()),
            age: None,
        },
    ];

    for user in &users {
        match send_notification(user) {
            Ok(()) => {}
            Err(e) => println!("Skipped: {}", e),
        }
    }
}

let else is a game-changer for the “guard clause” pattern. Before it existed, you had two options: nested if let (indentation hell) or match with an explicit diverge arm. let else keeps the happy path at the top indentation level.

Compare:

fn process_v1(input: Option<&str>) -> Result<u32, String> {
    // Nested if let — gets deep fast
    if let Some(s) = input {
        if let Ok(n) = s.parse::<u32>() {
            if n > 0 {
                Ok(n * 2)
            } else {
                Err("must be positive".to_string())
            }
        } else {
            Err("not a number".to_string())
        }
    } else {
        Err("no input".to_string())
    }
}

fn process_v2(input: Option<&str>) -> Result<u32, String> {
    // let-else — flat and readable
    let Some(s) = input else {
        return Err("no input".to_string());
    };

    let Ok(n) = s.parse::<u32>() else {
        return Err("not a number".to_string());
    };

    if n == 0 {
        return Err("must be positive".to_string());
    }

    Ok(n * 2)
}

fn main() {
    println!("{:?}", process_v1(Some("42")));
    println!("{:?}", process_v2(Some("42")));
    println!("{:?}", process_v2(None));
    println!("{:?}", process_v2(Some("abc")));
    println!("{:?}", process_v2(Some("0")));
}

process_v2 is flat. Each validation step is at the same indentation level. The happy path reads top-to-bottom without nesting. This is how I write every function that needs multiple validations.

while let: Looping on Refutable Patterns

while let loops as long as the pattern matches:

fn drain_queue(queue: &mut Vec<String>) {
    while let Some(item) = queue.pop() {
        println!("Processing: {}", item);
    }
    println!("Queue empty");
}

#[derive(Debug)]
enum Token {
    Word(String),
    Number(i64),
    End,
}

struct Lexer {
    tokens: Vec<Token>,
    pos: usize,
}

impl Lexer {
    fn new(tokens: Vec<Token>) -> Self {
        Lexer { tokens, pos: 0 }
    }

    fn next(&mut self) -> Option<&Token> {
        if self.pos < self.tokens.len() {
            let token = &self.tokens[self.pos];
            self.pos += 1;
            Some(token)
        } else {
            None
        }
    }
}

fn main() {
    let mut queue = vec!["third".to_string(), "second".to_string(), "first".to_string()];
    drain_queue(&mut queue);

    println!();

    let mut lexer = Lexer::new(vec![
        Token::Word("hello".to_string()),
        Token::Number(42),
        Token::Word("world".to_string()),
        Token::End,
    ]);

    while let Some(token) = lexer.next() {
        match token {
            Token::Word(w) => println!("Word: {}", w),
            Token::Number(n) => println!("Number: {}", n),
            Token::End => {
                println!("End of input");
                break;
            }
        }
    }
}

while let is particularly natural for iterators and channel receivers. Any time you’re pulling items from a source until it’s exhausted, while let Some(item) is the idiom.

Irrefutable Patterns in for Loops

for loops only accept irrefutable patterns. This works:

fn main() {
    let pairs = vec![(1, "one"), (2, "two"), (3, "three")];

    // Irrefutable: a (i32, &str) always destructures
    for (num, word) in &pairs {
        println!("{} = {}", num, word);
    }

    // Also irrefutable: struct destructuring
    struct Point { x: f64, y: f64 }
    let points = vec![Point { x: 1.0, y: 2.0 }, Point { x: 3.0, y: 4.0 }];

    for Point { x, y } in &points {
        println!("({}, {})", x, y);
    }
}

But this won’t compile:

// ERROR: refutable pattern in for loop
// for Some(x) in vec![Some(1), None, Some(3)] {
//     println!("{}", x);
// }

// Instead, use filter_map or flatten:
fn main() {
    let values = vec![Some(1), None, Some(3)];

    for x in values.into_iter().flatten() {
        println!("{}", x);
    }
}

Irrefutable Patterns in Function Parameters

Function parameters are irrefutable too. You can destructure, but you can’t do refutable matching:

// Irrefutable: always works
fn distance((x1, y1): (f64, f64), (x2, y2): (f64, f64)) -> f64 {
    ((x2 - x1).powi(2) + (y2 - y1).powi(2)).sqrt()
}

struct Color {
    r: u8,
    g: u8,
    b: u8,
}

fn to_hex(Color { r, g, b }: &Color) -> String {
    format!("#{:02x}{:02x}{:02x}", r, g, b)
}

fn main() {
    println!("{}", distance((0.0, 0.0), (3.0, 4.0)));
    println!("{}", to_hex(&Color { r: 255, g: 128, b: 0 }));
}

The Complete Picture

Here’s a reference combining everything from this series:

#[derive(Debug)]
enum Shape {
    Circle(f64),
    Rectangle(f64, f64),
    Triangle { base: f64, height: f64 },
}

fn classify_and_measure(shapes: &[Shape]) {
    for shape in shapes {
        // match — refutable, exhaustive
        let (name, area) = match shape {
            // Destructuring (Lesson 2)
            Shape::Circle(r) => ("circle", std::f64::consts::PI * r * r),

            // Guard (Lesson 3) + destructuring
            Shape::Rectangle(w, h) if (w - h).abs() < f64::EPSILON => {
                ("square", w * h)
            }

            // Or pattern (Lesson 4)
            Shape::Rectangle(w, h) | Shape::Triangle { base: w, height: h } => {
                let name = match shape {
                    Shape::Rectangle(..) => "rectangle",
                    _ => "triangle",
                };
                let a = match shape {
                    Shape::Rectangle(..) => w * h,
                    _ => 0.5 * w * h,
                };
                (name, a)
            }
        };

        // let-else with guard-like logic
        let area_str = format!("{:.2}", area);
        let Ok(parsed) = area_str.parse::<f64>() else {
            println!("Failed to format area");
            continue;
        };

        if parsed > 100.0 {
            println!("{}: LARGE (area = {})", name, area_str);
        } else {
            println!("{}: area = {}", name, area_str);
        }
    }
}

fn main() {
    let shapes = vec![
        Shape::Circle(5.0),
        Shape::Rectangle(4.0, 4.0),
        Shape::Rectangle(3.0, 7.0),
        Shape::Triangle { base: 20.0, height: 15.0 },
    ];

    classify_and_measure(&shapes);
}

My Mental Model

After years of Rust, here’s how I think about refutable vs. irrefutable:

Can this pattern fail? If yes, it’s refutable. Use if let, while let, let else, or match.

Is failure impossible for this type? If yes, it’s irrefutable. Use let, function parameters, or for.

Am I not sure? Try compiling it. The error message will tell you exactly what’s wrong and usually suggests the fix.

The refutable/irrefutable distinction is Rust being explicit about something every language deals with implicitly. In Python, you destructure a tuple and hope it has the right number of elements. In JavaScript, you destructure an object and get undefined for missing fields. Rust makes you decide upfront: is this pattern guaranteed to match, or do I need to handle the failure case?

That’s pattern matching in Rust. Eight lessons covering everything from basic match to typestate patterns to the refutable/irrefutable distinction. The through-line is always the same: patterns encode your expectations about data shapes, and the compiler verifies those expectations at compile time. Once you internalize that, you start seeing pattern matching opportunities everywhere — in error handling, state management, data transformation, and domain modeling. It becomes the default tool, not the special case.