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

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.

1 Like

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.

5 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

+1 and to reiterate others, prefer precedence matches is. Additionally a fix-it to apply parens for clarity would be nice

2 Likes

I feel this is one of the important reasons why is case would be better.
It is inconvenient that autocompletion doesn’t work with if case.

I feel awkwardness with is case, as is is type-casting-operator.
In pseudo-codes here it is becoming value-comparison-operator (although only if followed by case).

enum Fruit: Equatable {
    case apple(quantity: Int)
}

let fruit: Fruit = .apple(quantity: 4)
         let _: Bool = fruit == .apple(quantity: 4) // OK
/* 1. */ let _: Bool = fruit == .apple(_) // error   '_' can only appear in a pattern or on the left side of an assignment
/* 2. */ let _: Bool = fruit == .apple    // error   member 'apple(quantity:)' expects argument of type 'Int'

If 1. or 2. would be compilable, what would be the concern and why rather introducing new syntax preferable? (Plus you will also get negation as a bonus in this case)

I mean, to introduce new syntax or keywords, there needs to be convincing reasons why reusing existing keywords is not enough, I guess.

Excuse me if I'm missing a point.

I also like @tera 's extending approach!

1 Like

There are certain important parallels between types and enum cases. (Indeed, once upon a time, enum cases were capitalized.)

2 Likes

Capitalized, not camelCased.

enum E {
  case One, Two, Three
}

+1 from me. This solves a common frustration and fits well with existing syntax.

1 Like

Big +1 from me.
The only thing that would be better is synthesized Bool properties (bc key paths are so great) — but the proposed syntax makes the most sense given existing keywords (and is less "magical"). Introducing this would also make hand-crafted Bool properties much simpler to write, so I'm here for it!

RE: Implementation
What does implementing a feature like this in the compiler involve these days? Are there any guides or documentation? This seems like it wouldn't be tough to implement, but ramping up always seemed daunting. Last time I looked into it was like 2018. Contributing something to Swift has always been a goal of mine, but I never got past the first hurdle.

1 Like

I think the functionality is definitely needed. On another line of evolution, we're starting to look into factoring enum cases and other conditionally-available storage into key paths (and there's an implementation in progress. One bit of convergent evolution that key path support for enum cases suggest is the ability to project out payloads as instance members of the enum: if you can write structValue[keyPath: \.property] in order to get the value of struct.property, then it seems reasonable to expect that enumValue[keyPath: \.case] should give you the same value as enumValue.case would. And if that projection gives you a Bool or Optional, then you can test it within an expression using existing idioms like enumValue.case != nil.

<expr> is case <pattern> is still strictly more general since it works with any pattern, but it also seems like enum payload testing is likely to be the most common use case for it today. How much do you think we'd yearn for it if we already had a mechanism for testing enum cases in an expression? And if we had both, would you all as developers prefer to write enumValue is case .x or enumValue.x != nil?

12 Likes

I don’t quite follow. Are you suggesting that enumValue.x would evaluate to nil if enumValue is a case other than x? That seems confusing, especially if x’s payload is an optional. I’d rather have to decompose enumValue specifically in order to access its payload.

2 Likes