"else match" Extending Conditional Syntax

Motivation and Rationale

Rust's current conditional syntax provides excellent ergonomics with if let expressions, which have become a beloved feature for their clarity and conciseness. However, there exists an artificial limitation in the syntax that breaks the natural flow of conditional reasoning when developers need to combine boolean conditions with structural pattern matching.

The proposed extension addresses a fundamental inconsistency: while if let enjoys first-class support in both primary and secondary conditional positions, the more powerful match expression is artificially constrained to require block nesting when following an else clause.

User Experience and Readability Benefits

The proposed syntax directly maps to natural thought patterns. When reading code, developers think "if this condition, then this behavior, otherwise try this pattern matching." The current syntax forces unnecessary block nesting that breaks this mental flow.

Current syntax:

if condition {
    handle_positive();
} else {
    match value {
        Pattern1 => handle_pattern1(),
        Pattern2 => handle_pattern2(),
    }
}

Proposed syntax:

if condition {
    handle_positive();
} else match value {
    Pattern1 => handle_pattern1(),
    Pattern2 => handle_pattern2(),
}

Consistency and Design Philosophy

The proposal maintains consistency with existing conditional syntax patterns while extending them logically. Rust already demonstrates that conditional syntax should serve the developer's logical flow rather than impose arbitrary structural constraints.

The precedent of if let in secondary positions establishes that pattern matching belongs in conditional contexts, and match as the more general form deserves equal syntactic treatment.

fn parse_config(input: &str) -> Result<Config, ParseError> {
    if input.trim().is_empty() {
        Err(ParseError::EmptyInput)
    } else match input.parse::<toml::Value>() {
        Ok(toml_value) => {
            match toml_value.as_table() {
                Some(table) => Config::from_table(table),
                None => Err(ParseError::InvalidFormat),
            }
        }
        Err(_) => match input.parse::<json::Value>() {
            Ok(json_value) => Config::from_json(&json_value),
            Err(_) => Err(ParseError::UnsupportedFormat),
        }
    }
}

or

if let Some(resource) = acquire_resource() && resource.is_valid() {
    use_resource(resource);  // resource is moved and available here
} else match get_alternative_data() {  // different data source
    Alternative::Good(data) => process(data),
    Alternative::Bad => fallback(),
}

Addressing the match guard alternatives

While pattern guards can technically achieve similar results, they introduce significant practical drawbacks that make the proposed syntax extension valuable:

// Using pattern guards - problematic for complex conditions
match value.hard_job() { // This executes even if first condition passes!
    _ if expensive_validation() && another_check() && yet_another_condition() => handle_positive(),
    Pattern1 => handle_pattern1(),
    Pattern2 => handle_pattern2(),
}

// Proposed syntax - clear separation of concerns
if expensive_validation() && another_check() && yet_another_condition() {
    handle_positive();
} else match value {  // Only evaluated when needed
    Pattern1 => handle_pattern1(),
    Pattern2 => handle_pattern2(),
}

Pattern guards evaluate all patterns sequentially, potentially executing expensive computations unnecessarily. The conditional approach allows early termination and avoids pattern evaluation when the boolean condition already determines the flow.

Cognitive Load

// Guards obscure the primary logic flow
match input {
    _ if input.starts_with("HTTP/1.1") && input.contains("200 OK") && !input.contains("Connection: close") => handle_keepalive(),
    _ if input.starts_with("HTTP/1.1") && input.contains("200 OK") => handle_response(),
    _ if input.starts_with("HTTP/1.1") => handle_http_error(),
    _ if input.starts_with("HTTP/2") => handle_http2(),
    // ... more complex guard chains
}

// Proposed syntax - clear conditional flow
if input.starts_with("HTTP/1.1") && input.contains("200 OK") && !input.contains("Connection: close") {
    handle_keepalive();
} else if input.starts_with("HTTP/1.1") && input.contains("200 OK") {
    handle_response();
} else match input {
    line if line.starts_with("HTTP/1.1") => handle_http_error(),
    line if line.starts_with("HTTP/2") => handle_http2(),
    _ => handle_unknown(),
}

Practical Impact

The proposed syntax enables clear separation between boolean logic (which may involve side effects, moves, or expensive computations) and structural pattern matching, which guards cannot provide effectively.

This addresses real-world pain points in codebases of moderate complexity. Applications dealing with user input validation, configuration processing, and data transformation frequently encounter scenarios where boolean preconditions naturally precede structural pattern matching.

This proposal aims to enhance Rust's conditional syntax consistency and developer experience. What do you think about this proposal?

2 Likes

Isn’t the initial snippet equivalent to?

match value {
    _ if condition => handle_positive(),
    Pattern1 => handle_pattern1(),
    Pattern2 => handle_pattern2(),
}

The array/slice matching example can also be written as a single match AFAICT.

2 Likes

Yes:

    match array {
        a if a.is_empty() => handle_empty(),
        [x] => handle_single(*x),
        [a, b] => handle_pair(*a, *b),
        a => handle_complex_case(a),
    }

I'm cautiously positive about this suggestion, but the second example can already be written as a single match...

match array {
    [] => handle_empty(),
    [x] => handle_single(*x),
    [a, b] => handle_pair(*a, *b),
    more => handle_more(more),
}

(see here)

Additionally, I feel like this proposal is going to make people want these additional features:

  • match statements in other positions within an if-else chain:
    • match foo { ... } else ..., where the else clause is entered if none of the match cases matched
    • if ... { ... } else match { ... } else ..., similarly
  • the compiler does flow analysis over an entire if-else chain and doesn't require embedded match statements to include cases that cannot reach them (to some extent this is already a pain point for match inside else [if] blocks, but if match is a first-class citizen of if-else chains, that makes it more surprising when this doesn't work)

You should think about whether you want to roll these features into your proposal.

4 Likes

Thank you very much for the review. I have expanded the proposal based on your comment. At the moment, I am proposing to reduce the cognitive load on conditions within match options, as this becomes cumbersome and "late-computed" for more complex conditions

These are excellent points about the consistency implications of the proposal. I agree that treating match as a first-class conditional citizen creates expectations of symmetrical functionality.

Regarding match with else clauses

// This would indeed be expected if we extend match to else position
match foo {
    Pattern1 => handle1(),
    Pattern2 => handle2(),
} else {
    // Handle when no patterns match
    fallback()
}

However, this conflicts with Rust's exhaustiveness checking philosophy. The match expression is designed to be exhaustive - if properly structured, no patterns should remain unmatched. I think, introducing an else clause would undermine this safety guarantee and create confusion about when exhaustiveness applies.

About flow analysis complexity:

I am skeptical about the complexity of analyzing the execution flow.

fn foo0(opt: Option<i32>) -> i32 {
    if let Some(x) = opt
        && x > 0
    {
        x
    } else match opt { // Proposal syntax
        Some(y) if y <= 0 => std::process::exit(y),
        None => std::process::exit(1),
    }
}

fn foo1(opt: Option<i32>) -> i32 {
    if let Some(x) = opt
        && x > 0
    {
        x
    } else {
        match opt {
            Some(y) if y <= 0 => std::process::exit(y),
            None => std::process::exit(1),
            // _ => todo!() - Error: missing match arm: `Some(_)` not covered
        }
    }
}

fn foo2(opt: Option<i32>) -> i32 {
    if let Some(x) = opt
        && x > 0
    {
        x
    } else if let Some(y) = opt
        && y <= 0
    {
        std::process::exit(y)
    } else if let None = opt {
        std::process::exit(1)
    }
    // Error: `if` may be missing an `else` clause
    // `if` expressions without `else` evaluate to `()`
}

I believe the original proposal should remain focused on the else match extension specifically, as it addresses the clear inconsistency between if let and match syntax without introducing exhaustiveness complications. The additional features you mention would require separate, more complex proposals that address the fundamental design questions around non-exhaustive matches and enhanced flow analysis.

The core benefit of else match - eliminating block nesting while maintaining existing safety guarantees - justifies its inclusion without expanding into these more complex territory. However, it makes sense to consider the potential compatibility of this syntax if the addition of match else is considered in the future. As of now, I believe that else match will be:

  1. a syntactic sugar for nested match in else
  2. match options will technically be expanded in the if let construct

So far I still haven't seen an example that couldn't just be written as a single match. I think you need some better examples, ideally from real-world code.

3 Likes

This is a very old idea. The first version of it I found is from 2016 If-else could allow omitting braces on the else · Issue #1616 · rust-lang/rfcs · GitHub and then someone wrote an RFC that was rejected in 2017 Add 'else match' blocks to if expressions. by phoenixenero · Pull Request #1712 · rust-lang/rfcs · GitHub

Has anything materially changed since then? Nothing comes to mind to me.

Reusing an 8-year old post to address this: https://github.com/rust-lang/rfcs/pull/1712#issuecomment-277857697.

If it's something that can't just be written as

match value {
    _ if condition => handle_positive(),
    Pattern1 => handle_pattern1(),
    Pattern2 => handle_pattern2(),
}

then the extra nesting is maybe even good.

If this was sufficient justification to add it, it's also be sufficient justification to add else for and else loop and a whole bunch of other stuff -- even things like loop for. So I think you need a much stronger reason that else match is special, or you do need to argue that those other extensions would also be similarly good and probably worth doing to.

I don't think that the syntactic version of this is what people would want in if let.

More likely, to me, if there was going to be an else match it'd be a typestate-style thing so that you can do

let Ordering::Equal = … stuff …
else match {
    Ordering::Less => …,
    Ordering::Greater => …,
};

where it's not a syntactic contraction, but an extended whole-construct thing where the exhaustiveness checking is aware of both the let pattern and the match patterns, so you can handle the remainder of the value that was pattern-matched in a nice way that's not possible with today's let-else.

Just removing an extra set of braces in there doesn't seem worth bothering at all, but giving new expressivity might be.

(That said, it's still just convenience, as it's already possible with plain match, so it'd need a survey showing in real code that it'd be widely applicable, not just "well it sounds nice" or "it happens sometimes".)

2 Likes

What about an example from real code:

  1. svg_loader.rs - source
  2. parker.rs - source
  3. binding_model.rs - source

match and if..else both select one of several "body" expressions to evaluate based on a series of "conditions" and/or "patterns". So they are related to each other, and they are both different from looping constructs. So I don't think your slippery slope argument goes through.

However,

fully agreed with this. I see value in the proposal if and only if exhaustiveness checks consider the entire if-else-match chain.

Note that this advantage only applies to let-else match.

It doesn't apply to if BOOL_COND { … } else match … { … }, since that's not a pattern.

It also doesn't apply to if let PAT = EXPR { …A… } else match BLAH { … } because they're matching different things.

And the special case of if let PAT = EXPR { …A… } else match { PAT2 => EXPR3, … } doesn't make sense to offer because that's just match EXPR { PAT => …A…, PAT2 => EXPR3, … }.

So even assuming that such a proposal would be good for let-else match, it still doesn't mean that if-else match is good.


Really, part of the problem is that if-else chaining is just not a great way to do things. The much better construct is cond, which would side-step all these problems by not needing chaining. (In the same way that there's no chaining for multiple match arms, since the construct encloses all of them.)

I like what boats had to say about this:

else if is an expected/understood construct from other languages. Like much of our control flow*, if/else is empowered by familiarity as much as convenience, and making it act strangely from other languages only because its more general in some abstract sense doesn't make it more intuitive or usable.

*The most glaring example of this "familiarity sugar" is while: I doubt we would have considered that use case deserving of sugar if it weren't for the fact that its a universally common construct in imperative languages.

Notably, even in languages where if (cond) { BLOCKA } else switch (EXPR) { ARMS } is legal, it seems to be against the de-facto rules to ever write it that way, with every style guide saying that else needs to have braces even though the language doesn't.

7 Likes