Proposal draft for `is case` (pattern-match boolean expressions)

Hi, all. I recently ran into a situation where is case would have been really helpful, and that prompted me to take a look at the history of this moderate proposal. It turns out it's been discussed [every] [year] [since] [Swift] [went] [open] [source], and that's just in the Evolution section.

Due to my RSI I can't actually be the one to implement it, but one thing I can contribute is a written-up proposal draft. Thoughts? Implementation volunteers? (And no, assume it's too late for this to make 5.7 even if everything comes together quickly.)


is case: pattern-match boolean expressions

Introduction

Users regularly ask for an easy way to test if an enum matches a particular case, even if the enum is not Equatable, or if the case in question has payloads. A quick search of the forums turned up threads as old as 2015 asking about this---that is, about as old as the open source project itself.

Proposed solution

A new expression, <expr> is case <pattern>, that evaluates to true or false based on whether <expr> matches <pattern>.

Detailed design

The Swift grammar gains a new expression production:

infix-expression -> is case <pattern>

The pattern must not have a trailing type-annotation, and recursively must not contain a value-binding-pattern (see Future Directions below).

<expr> is case <pattern> should be considered equivalent to the following code:

({ () -> Bool in
  switch <expr> {
  case <pattern>: return true
  default: return false
  }
})()

In particular, the pattern may contain expressions matched with ~=, just as in switch and other pattern-matching constructs. The syntax is chosen to both imply a boolean output (is) and evoke existing pattern-matching constructs (case).

Some examples:

result is case .success(_)
result is case .failure(MyError.fileNotFound)
value is case (0..<limit)?

Precedence

By analogy with <expr> is <type>, this expression should be usable within &&/|| chains. That is, x && y is case .z && w should be equivalent to x && (y is case .z) && w. At the same time, other binary operators need to bind more tightly: x is case y ..< z should be interpreted as x is case (y ..< z). This behavior is already implemented for chains of infix-expressions using precedence, but adding expression-patterns to the mix may be tricky to implement.

Open question: should x ?? y is case .z be treated as x ?? (y is case .z) or (x ?? y) is case .z? The former matches is's CastingPrecedence, designed around as?, but the latter is still an option, and both have plausible uses: alwaysDark ?? (systemMode is case .dark) vs (overriddenMode ?? systemMode) is case .dark. The precedence of is case should be higher than ComparisonPrecedence no matter what, though.

If the pattern is known to always or never match the expression at compile time, the compiler should emit a warning. This includes "irrefutable" patterns that merely destructure their expression; these are not significantly different from type-casting patterns that are statically known to be upcasts, or values known to be out of range through constant propagation.

Source compatibility and ABI

This is an additive change to expression syntax that requires no additional runtime support; it has no source- or binary-compatibility implications beyond not being available in earlier versions of the compiler.

Alternatives considered

This is the longest section; it covers alternate syntaxes as well as proposals in similar spaces. I also put some Future Directions behind this fold.

Per-case optional properties

For years now there's been an idea that for case foo(bar: Int, baz: Int), the compiler could synthesize some or all of the following computed instance properties:

  • isFoo: Bool
  • asFoo: (bar: Int, baz: Int)?
  • bar: Int?
  • bar: Int (if every case has a field bar: Int)

This would handle the most common use for is case, checking if a value with known enum type has a particular case. However, it does not cover all the use cases, such as matching nested values. Even if such a feature is proposed and accepted through the evolution process, is case would still be useful.

Control-flow statements as expressions

If control-flow statements were expressions, you could implement this with if case <pattern> = <expr> { true } else { false }, without having to wrap in a closure like my expansion above. However, this is still pretty verbose, and even Rust, which has generalized control-flow expressions, still provides a matches! macro in its standard library.

case <pattern> = <expr>

There have been a handful of other proposed spellings over the years, most notably case <pattern> = <expr>, by analogy with the existing if case. However, while this syntax is not likely to be ambiguous in practice, it does suffer from the main flaw of if case: the pattern comes first and therefore cannot be code-completed from the expression when typed left-to-right. The single = also suggests assignment even though the result is a boolean.

<expr> case <pattern>

This is more concise, but would make it harder to parse switch statements:

  doSomething()
case is UIButton // missing colon
  doSomethingElse()

While this example is contrived, it shows how the compiler would have to jump through extra hoops to understand incomplete or erroneous code. So it's a good thing no one has seriously suggested this.

Special-case == or ~=

People like using == to compare non-payload cases, and ~= is already used to match expression patterns. We could change the compiler to treat these differently from normal operators, allowing <expr> == <pattern> or <pattern> ~= <expr>. I'm personally not a fan of this, but I can't think of an inherent reason why it wouldn't work for enum cases. I'm hesitant to use == when other forms of matching are involved, but ~= doesn't have that problem. It does, however, put the pattern on the left (established by existing implementations of the operator function), which again is sub-optimal for code completion. From a learning perspective, operators are also generally a bit harder to read and search for.

Change is

In theory, the existing cast-testing syntax <expr> is <type> could be expanded to <expr> is <pattern>, with <expr> is <type> effectively becoming sugar for expr is (is <type>). This makes a very satisfying, compact syntax for pattern-matching as a boolean expression...but may add confusion around pattern matching in switch statements, where case <type> is disallowed, and case <type>.self is an expression pattern. I don't think there's an actual conflict here, but only because of the requirement that types-as-values be adorned with .self. Without that, case is <type> would check runtime casting, but case <type> would invoke custom expression matching, if an appropriate match operator is defined. (SE-0090 proposed to lift this restriction, but was deferred.)

Additionally, because there's an implementation of expression pattern matching that uses Equatable, we run into the risk of adding x is y to the existing x == y and x === y. Having too many notions of equality makes the language harder to learn, as well as making it easier to accidentally pick the wrong one.

is case sidesteps all these issues, and doesn't preclude shortening to plain is later if we decide the upsides outweigh the downsides.

Wait for a Grand Unifying Pattern-Matching Proposal

There are a good handful of places where Swift's existing pattern-matching falls short, including if case as discussed above, the verbosity of let in patterns where case is already present, the lack of destructuring support for structs and classes due to library evolution principles, and the inability for expression-matching to generate bindings. Proposals to address some or all of these issues, especially the last, might come with a new syntax for pattern matching that makes sense in and outside of flow control. Adding is case does not help with these larger issues; it's only a convenience for a particular use case.

This is all true, and yet at the same time this feature has been proposed every year since Swift went open source (see the Acknowledgments below). If something else supersedes it in the future, that's all right; its existence will still have saved time and energy for many a developer.

Future Direction: Negation

This proposal provides a convenient way to check if an expression matches a pattern, but while that composably extends to !(<expr> is case <pattern>), the required parentheses feel clunky. Allowing <expr> is not case or similar would help code read more smoothly.

Prefix and postfix operators not composing well is a concern for all existing binary operators, however (particularly as?), so it may or may not be worth special-casing is case.

Future Direction: Value Binding

The limitations on value binding come from a simple question: what happens to those bindings if the match fails? They have to be attached to a conditional scope, like the existing if case, while case, and guard case syntaxes. Yet it's also been recognized that if case is suboptimal, because the pattern comes first and therefore cannot be code-completed from the expression when typed left-to-right.

One could allow any of the following syntaxes for this:

  • if <expr> is case <pattern> (downside: looks exactly like the boolean expression, but has additional powers due to its position)
  • if <expr> as? case <pattern> (downside: postfix ? usually implies Optional, but there's no Optional here)
  • if <expr> as case <pattern> (downside: as in patterns does a test and a runtime cast, but as in expressions is a compiler-inserted conversion, and this looks more like the latter than the former)
  • if <expr> case <pattern> (downside: doesn't parallel is case as closely, but otherwise has few issues)
  • if <expr> matches <pattern> (downside: matches isn't an existing keyword, so it's trickier to parse this)
  • (something else)

It's also worth noting that a hypothetical <expr> as! case <pattern> would also allow for value bindings, answering the question of "what happens when match fails" with "abort". This operation has been requested as well, though not as frequently as is case. However, for an arbitrary pattern containing value-binding this would be a statement rather than an expression (what type would the result have?). That would make it the first value-binding statement that doesn't start with a keyword. More thought should be given to possible syntaxes for a forcibly-applied pattern, or if it is more reasonable to solve this another way, possibly limiting support to enum cases and not arbitrary patterns.

Acknowledgments

  • Andrew Bennett was the first person I could find suggesting the spelling is case for this operation, way back in 2015!
  • Alex Lew (2015), Sam Dods (2016), Tamas Lustyik (2017), Suyash Srijan (2018), Owen Voorhees (2019), Ilias Karim (2020), and Michael Long (2021) have brought up this "missing feature" in the past, often generating good discussion. (There may have been more that I missed as well, and this isn't even counting "Using Swift" threads!)
  • Jon Hull (2018), among others, for related discussion on restructuring if case.
70 Likes

All I can say is, woooooooo!

9 Likes

I’ve long felt this is a conspicuous gap.

The linguistically awkward phrasing of “if case” and especially “if case let” has always rankled me — I’d rather Swift had used “match” instead of “case,” maybe — but given precedent in the language, the proposed syntax seems like a good one.

5 Likes

My code is littered with boilerplate properties akin to

var isNumber: Bool { if case .number = op { return true } else { return false } }

to overcome this gap in the language. It would be a pleasure to get rid of all that verbose ceremony. I second @xwu in saying woooooooo!

3 Likes

I take back that it's all that I can say; I should add something of substance:

Given that we're starting to have foo as any P and other such double-barreled keyword-based operators, I think it's important that is case has precedence as though we're writing the regular operator is followed by (case <pattern>). Where this is possibly ambiguous we can make parens mandatory, but I wouldn't magically make is and is case behave differently--hard to explain (IMO).

9 Likes

That's a good question. I think it's because this is ultimately syntactic sugar—very nice sugar, used to good effect, but sugar nonetheless. Historically, that kind of thing has been deprioritized while other parts of the language get filled out, often with pressure from Apple on their latest focus. I was part of this too, when I was at Apple. But Swift 5.7 has had a lot of changes that are about making it easier to use existing features along with making it possible to express new things, and so I'm hopeful that there's room for something like this.

The limited resource, though, has always been implementors. This feature, made up of existing capabilities, is going to be relatively easy to implement, and yet it's still going to need to touch SwiftSyntax, the parser, the type checker, code completion, SILGen, possibly debug info, and possibly something else I've missed. There just aren't that many people outside of Apple who contribute to the compiler, and Apple folks always schedule themselves / get scheduled to the fullest, so it's hard for them to take on something extra.

8 Likes

+1, this will be quite welcome.

Are there ideas for how this can be extended to allow checking whether two variables have the same enum case (even if their associated values are different)?

1 Like

Nice!

the current syntax is honestly kind of ugly, especially when working with if

From alternatives section:

I like a variation of the above alternative, IMHO it deserves consideration:

// given this enum:
enum E {
    case foo(bar: Int, String)
    case qux
}

// let's assume this was autogenerated:
// (see implementation details below)
extension E {
    var foo: (bar: Int, String)! { ... }
    var qux: E! { ... }
}

// then we can write:
var e = E.foo(bar: 1, "hello")
print(e)                    // foo(bar: 1, "hello")
print(e.qux)                // nil
print(e.foo)                // Optional((bar: 1, "hello"))
print(e.foo.bar, e.foo.1)   // 1 hello
print(e.foo?.bar, e.foo?.1) // Optional(1) Optional("hello")
print(e.foo != nil)         // true
print(e.qux != nil)         // false
print(e.foo?.bar != nil)    // true
let (bar, baz) = e.foo
print(bar, baz)             // 1 hello
print(e.qux)                // nil
e.foo.bar = 2   // mutating associated value! 😃 (traps if not foo)
print(e.foo)    // Optional((bar: 2, "hello"))
e.foo?.1 = "world" // mutating associated value by index this time
print(e.foo)    // Optional((bar: 2, "world"))
Implementation details
// let's assume this is autogenerated:
extension E {
    var foo: (bar: Int, String)! {
        get {
            switch self {
            case let .foo(bar: bar, baz): return (bar: bar, baz)
            default: return nil
            }
        }
        set {
            let val = newValue!
            self = .foo(bar: val.bar, val.1)
        }
    }
    var qux: E! {
        get {
            switch self {
            case .qux: return E.qux
            default: return nil
            }
        }
        set {
            precondition(newValue != nil)
            self = .qux
        }
    }
}

Edited: updated the sample and implementation details

1 Like

I think the thing to search for is “Case Paths”, but I don’t think that makes is case unnecessary. The most common use might be to match discriminators, but patterns in switches do more than that already.

7 Likes

If it is an open question without clear answer, I fear it would always be, and will require most code reader to search for the answer every time they encounter this construct (especially as it is not mean to be something they see everyday).

I would go for the mandatory parentheses in such case.

2 Likes

I'm not sure if you covered this in the original post but would this supersede the current if case <pattern> = <expr> syntax? Also, do you think the following would make for a good future direction?

enum Either { case a(Int), b(Int) }
let either = Either.a(0)

if let either is case .a(payload) {
  print(payload)
}

if <expr> is case <pattern> is what makes most sense to me. I agree that it looks like a boolean expression, but I see this as an upside, not a downside. let is already a special case when inside an if, here is no different.

This reads very well for me:

if result is case .success(let value) { ... }

It's also convenient that if you start with this:

if result is case .success { ... }

you don't have to make unnecessary syntactic changes if you need access to the value later: you can just add (let value) to the pattern.

13 Likes

I think it would be useful especially if it could be composed with where to make further refining pattern matches in switch/case or for/in statements. I'd move the let after the case though:

if either is case let .a(payload) { ... }

The example from that other thread I linked above would turn into:

switch firstEnum {
case let .firstCase(firstStruct)
     where firstStruct.secondEnum is case let .secondCase(secondStruct),
           secondStruct.id == someOtherId:
    ...
default:
    ...
}

But I guess that would mean there'd be a new way to write the existing if case/let:

if firstEnum is case let .firstCase(firstStruct),
   firstStruct.secondEnum is case let .secondCase(secondStruct),
   secondStruct.id == someOtherId
{
    ...
}
4 Likes

I think this is certainly a problem worth solving, and this looks like a great simple solution.

I've thought about this before, and had a different approach in mind. I don't know if it's better or worse, but I'll toss it out there:

I thought it would be nice if each case of an enum also produced a new corresponding type, as a subtype of the enum. Here are some of the features this allows:

  1. Detecting a case ignoring any associated values

    (Akin to is case .success(_))

    Take Result for an example. It might have two subtype auto-generated: Result.Success and Result.Failure. You would be able to use conventional dynamic type checking with is, like:

    if result is Result.Success { ... }
    
  2. Accessing associated values

    This subtype can act like a tuple, with one named property per associated value of the enum case. This could be used in if statements like with any other value:

    if let success = result as Result.Success, success.value == 123 { ... }
    
    
  3. Extracting functions that operate on a single case

    Suppose you had a switch over an enum value, with long case bodies. Today, these cases are hard to extract, because you lose the specific case information:

    switch someEnum {
    case a: handleCaseA(a) // case information is lost in this call, going back to SomeEnum
    ...
    }
    
    func handleCaseA(_ a: SomeEnum) {
        guard case .a(payload) = a else { return } // Need to manually narrow down the type again
        
        print(payload)
        // some long body
    }
    

    If each case got its own type, this would have a very nice solution:

    switch someEnum {
    case a: handleCaseA(a) // case information is lost in this call, going back to SomeEnum
    ...
    }
    
    func handleCaseA(_ a: SomeEnum.A) { // Only a "SomeEnum.a" can make it here
        // Only a `SomeEnum.a` can make it here, no checking needed
        
        print(a.payload)
        // some long body
    }
    

Some loose ends:

  1. What would these subtypes be called?
    • ...and how do we keep those names from colliding with type param names of generic enums?
  2. How do you access unnamed associated values?
    • like a tuple? .0, .1, .2, ...
    • if there's only one, perhaps some standard name, like .value?
2 Likes

I'll add another reason not to use is alone.

This would be very confusing:

if value is 0..<10 { ... }

It reads like equality, but the pattern matching meaning is to check if the value is contained in the 0..<10 range. The is keyword alone is insufficient to announce we're using pattern matching here.

I think potential for confusion when the pattern does not check for equality is a much stronger reason to avoid this syntax than partial duplication of the == operator.

7 Likes

I prefer for x ?? y is case .z to be treated as x ?? (y is case .z). I can't articulate my reasoning right now, other than that treating it as (x ?? y) is case .z can be confusing in the direction of PHP's nightmarish left-associative ternary operator (especially if x and y are long expressions).

I'm a bit concerned about this. Adding is not could mislead folks into believing that Swift is kinda like Python and that this is another way to write !=. The case keyword makes things a bit better, not to mention that this syntax may never be added if folks rarely use negated pattern-match expressions. Nevertheless, I wonder if it would make sense to special-case ~= as outlined above in the pitch, to make sure that this feature is extensible.

4 Likes

I like this. It is quite similar to this one above (updated) with some differences, e.g. my version introduces dynamic variables with names matching the names of enum constants and does not generate an explicit nominal type (or a type alias), assuming the type would match the (tuple) type of the associated values for the case (and for a case value without associated value the type matches the type of the enumeration), the type is deliberately implicitly unwrapped optional which I believe is a good thing in this case, accessing associated values either by name or by index, and new to swift - write access for individual associated value variables. This alternative doesn't have all features of the pitch proposal and vice versa, the pitch proposal doesn't have all features of this alternative - still there's a significant overlap between the pitch and the alternative (feature wise), and there's a huge overlap between the two alternatives being discussed.

:new: updated the sample in the linked post.

1 Like

As much as I'd like the consistency that we'd get from allowing this last line to compile, to match the others…

if case 0... = 1 { }
if 0... ~= 1 { }

let optional = Int?.none

if case .none = optional { }
if .none ~= optional { }

if case .some = optional { }
if .some ~= optional { }

…I think that ship has sailed. If you were to solve this with ~=, enum cases with associated values would only sometimes be closures when not "prefixed" with case, whereas now, they predictably always are closures.


My current solution, that I would love to delete in favor of is case, uses that feature and looks like this:

if Optional.some ~= Never?.none { }
if .none ~= Void?.none { }
/// Match `enum` cases with associated values, while disregarding the values themselves.
/// - Parameter case: Looks like `Enum.case`.
public func ~= <Enum, AssociatedValue>(
  case: (AssociatedValue) -> Enum,
  instance: Enum
) -> Bool {
/// Match non-`Equatable` `enum` cases without associated values.
public func ~= <Enum>(pattern: Enum, instance: Enum) -> Bool {
2 Likes