[Pitch] is case expressions

Again, I'll note that dot-syntax is merely a theoretical suggestion that I find easiest to motivate given the existence of optional-chaining, and that we need ways to compose property access with case access, and we need ways to compose key paths with case paths.

I'd be perfectly happy with any syntax here, but I still haven't seen any suggested alternatives fleshed out that allow for these compositions.

I'll try not to derail the topic with dot-syntax further, but will say that the lack of clarity around expressive enum payload access makes it much harder to evaluate this pitch, and whatever syntax we do come up with might motivate a different syntax than is case.

7 Likes

Stephen, thanks for your feedback. While I'm just as excited for first class support for case paths, I see them as a more general tool that's more capable, and fits in right alongside is case.

One of the main use cases of is case is to directly test against enum cases and where you might be able to achieve that with case paths or dot-syntax access, it's definitely not as concise or ergonomic.

Comparing is case to case paths

Let's jump directly to what could be said to be the most ideal syntax for case paths (ignoring the setting/embedding functionality for the sake of this example):

enum Destination { 
  case .messageThread(id: Int)
}

// Constructing a case path, treated similarly to key paths and may compose with them
let threadPath = \Destination.messageThread 

// Extracting values
let destination = Destination.messageThread(id: 42)
let id = destination[keyPath: \.messageThread] // Int?

// Checking against an enum case
destination[keyPath: \.messageThread] != nil // true
// Usage inline 
HeaderView(inThread: destination[keyPath: \.messageThread] != nil)

// Contrasted with the proposed `is case` syntax
destination is case .messageThread // true
// Usage inline
HeaderView(inThread: destination is case .messageThread)

To answer your question: if we are given such a syntax in the future, would we still want the additional surface area of is case ? I do think is case is able to express the intent to check if an enum is a particular case much more succinctly and with less ceremony than the case path alternative.

This is especially so if the case is known at compile-time. The case path approach is more useful if we needed to refer to a case in the abstract without instantiating the enum.

In particular, the ability to match a pattern directly inline (when extended with the ~= operator) could be very useful as in the following Point-free example

Show more

Assuming context from the code sample in this case study:

enum Action {
  case binding(BindingAction<State>)
}

// With `is case`, tacking on functionality when `Action` matches a specific binding
if action is case .binding(\.$stepCount) {
  ...
}

// The same with a case path (we can't leverage the `~=` operator here)
if action[keyPath: \.binding]?.keyPath == \.$stepCount {
  ...
}

Comparing is case to dot-syntax access for associated values on enums

Suppose the compiler makes accessing associated values as easily as properties (without code synthesis), vended as optionals:

enum Destination {
  case inbox
  case messageThread(Int)
}

// Assume the following is available implicitly:
extension Destination {
  var messageThread: Int? // Returns the associated value or `nil` if the value is not in the `messageThread` case
}

// Checking against an enum case
destination.messageThread != nil // true
// Usage inline 
HeaderView(inThread: destination.messageThread != nil)

// Contrasted with the proposed `is case` syntax
destination is case .messageThread // true
// Usage inline
HeaderView(inThread: destination is case .messageThread)

Here I would still say that is case describes the intent more succinctly without the ceremony of comparing to nil.

3 Likes

After looking over the pitch a second time, and understanding that it aims solely to improve the ergonomics of pattern matching, I believe the proper spelling for this feature is “matches”.

As in, “<expression> matches <pattern>”.

In support of this, note that we are talking about pattern matching. And in every discussion or description of pattern matching, it is called “pattern matching”, because the name of the operation is pattern matching, and the thing that it does is match patterns.

Swift takes pride in naming things well, and this is a great opportunity to supersede the notoriously unintuitive “if case” spelling with something far more natural, that reads correctly as written, and which actually describes the operation being performed.

Using “matches” will make pattern-matching code much easier to read and understand.


Here are some examples from the pitch, with matches as the keyword:

print(destination matches .inbox) // false
print(destination matches .messageThread) // true
print(destination matches .messageThread(id: 0)) // false
print(destination matches .messageThread(id: 42)) // true

foo matches .bar // enum case
foo matches .bar(42) // enum case with associated values
foo matches .bar(42, _) // enum case with partially matched associated values
foo matches 42 // integer literal
foo matches true // boolean literal
foo matches "A string" // string literal
foo matches bar // other expression

To me, they are a lot more sensible this way.

14 Likes

I find this sort of syntax appealing at a surface level, but there are a few reasons I think synthesized "is case" or "as case" declarations aren't a good fit here.

For one, I'm not so sure we would be happy with enabling this sort of functionality for all enums by default. For enum cases without associated values we'd have to synthesize Void? accessors, which I find especially unidiomatic:

enum Destination {
  case inbox
  case settings
  case profile
}

destination.inbox // Void?
destination.settings // Void?
destination.profile // Void?

Littering all of these unidiomatic properties on enums that don't get any value out of them seems less-than-ideal. This isn't an issue with a new operator like is case since operators don't show up in autocomplete.

Another important aspect is that there isn’t an existing precedent for synthesizing these sorts of user-facing properties by default. In cases where the compiler synthesizes declarations, they're either opt-in using advanced language features (@dynamicMemberLookup) or the synthesized declarations aren’t so directly user-facing (e.g. Codable synthesis, which isn't ever really called directly by the user).

The developer experience of working with these synthesized properties would likely be a bit poor. One practical limitation is that it wouldn’t possible to add documentation for these properties. When using the strongly-typed KeyPath API, @dynamicMemberLookup's synthesized properties inherit the documentation of the property they reference via the keypath. This doesn't work well for enum cases, since any documentation associated with the case wouldn't do a good job describing a declaration with a different type.

Most users of Swift can accomplish quite a lot without ever directly consuming any of these sorts of synthesized declarations. To me this indicates that user-facing declaration synthesis isn't really appropriate for functionality as core to the language as working with enum values.

3 Likes

I find this appealing since it's just a single keyword rather than two. I'd certainly support this sort of spelling as an alternative to is case.

I think it's worth considering that the language consistently uses case x everywhere you can do pattern matching (right?). To me this feels valuable because it clearly establishes a relationship between if case, switch { case: } and is case, and suggests that they support the same type of pattern matching functionality.

On the other hand, perhaps establishing a bit of contrast between the two use cases is actually a good idea. Would using a different keyword here make it less surprising that you aren't able to bind associated values when using this new expression type? (e.g. that you can only bind associated values with case .messageThread(let id) and not matches .messageThread)

5 Likes

@matthewcheok @cal Thanks for both replying. I'm going to de-emphasize my dot syntax solution again, since the conversation seems to be getting hung up on details around that, but my main point is that the syntax of is case (or matches) might be designed differently if we could first shed some light on the following problem spaces:

  • What should it look like to extract an enum payload in an expression?
  • What should it look like to compose property access with case access?
  • What should it look like to compose key paths with case paths?

It's quite possible that is case still fits in perfectly well with whatever solutions to the above problems ends up looking like. It's also quite possible that is case can help shed some light on how these solutions should be designed.

On the other hand, it's might be that solutions to the above problems leads to an alternative design that ties everything together differently.

Does anyone have a vision for what solutions to these problems may look like, especially from the perspective of is case?

14 Likes

is case <pattern> would have the same behavior as a switch with case <pattern>.

That being said, I'm not exactly sure what the expected behavior of a switch statement would be in cases where an enum has multiple cases with the same base name. I'm a bit confused when I run this sample code:

enum MyEnum {
  case foo(bar: Int)
  case foo(baaz: Int)
}

func match(_ value: MyEnum) {
  switch value {
    case .foo:
      print("matches .foo")
    default:
      print("doesn't match .foo")
    }
}

match(.foo(bar: 42)) // prints "doesn't match .foo"
match(.foo(baaz: 42)) // prints "matches .foo"

I don't know how the compiler chooses which case to match here. It seems arbitrary.

Even more strangely, this code compiles successfully but crashes with "Fatal error: unexpected enum case while switching on value of type 'MyEnum'":

enum MyEnum {
  case foo(bar: Int)
  case foo(baaz: Int)
}

func match(_ value: MyEnum) {
  switch value {
    case .foo:
      print("matches .foo")
    }
}

match(.foo(baaz: 42)) // prints "matches .foo"
match(.foo(bar: 42)) // causes a crash

So this seems pretty poorly defined today, and I have no clue what the expected behavior would be.

10 Likes

I find the examples you shared above to be pretty reasonable, although I understand your hesitation around the extra parenthesis that would be required:

I wonder if a spelling like .isCase(<pattern>) and .asCase(<enum-case>) would feel better here:

// isCase(<pattern>)
let destination = Destination.messageThread(id: 42)
print(destination.isCase(.inbox)) // false
print(destination.isCase(.messageThread)) // true
print(destination.isCase(.messageThread(id: 0))) // false
print(destination.isCase( .messageThread(id: 42))) // true

// asCase(<enum-case>)
let id/*: Int?*/ = destination.asCase(.messageThread)

screen.asCase(.messageThread)?.map(MessagesView.init(inThread:))

// case foo(bar: Int, baz: Int)
_: Int? = value.asCase(.foo)?.bar
_: Bool? = value.asCase(.foo)?.baz.isMultiple(of: 2)

\Destination.asCase(.messageThread)

This seems like it may be the best of both worlds -- it's a simple syntax that doesn't require quite so many magic synthesized properties, but also doesn't use an operator so works really naturally within expressions, optional chaining, and key paths.

Thoughts?

I could see how introducing an is case expression could potentially cut us off from this sort of direction, since it would be weird to have expr is case x and expr.asCase(x). Perhaps expr matches x and expr.asCase(x) could coexist nicely though.

2 Likes

Absolutely agree.

expr.asCase(x)   // yucky 
expr matches x   // elegant but requires new keyword
expr =~ x        // even better
1 Like

I just wanted to say that I think the proposed is case syntax is great exactly as proposed.

  • This would not be better spelled with another keyword. For better or worse, case is the keyword already used for pattern matching in the language. Using case here makes it immediately clear to me how this works: patterns here work the same as in other pattern matches, except you can’t bind to new variables since there’s no new scope.
  • This would not be better spelled with ==. The == operator tests for equality. That is not what’s happening here. What’s happening here is pattern matching.
  • This would not be better as some imagined case projection mechanism. I too want case paths and better ways of reading and writing case payloads, but all those features are orthogonal to this. The most common use of this would be matching against cases when you don’t care about the payload. I would also imagine shape is case .path to have better performance than anything that ends with != nil, since it doesn’t need to extract the payload. (As always, maybe the compiler would optimize it away, but if the payload is large and this is in a hot filter, do you really want to rely on that maybe?)
13 Likes

This would be a useful addition – thank you for pitching it!

I often run into this problem in tests and resort to writing helpers for each case to extract associated values or check if the value is that case. This is tedious, error prone and results in numerous lines of boilerplate.

I like the proposed spelling of this: is case is clear at the point of use, consistent with pattern matching and indicates a boolean result.

My gut feeling is that x ?? y is case .z should be treated as x ?? (y is case .z); only in that I think it would be a little surprising if it was treated differently to x ?? y is Foo.

1 Like

I see there is a case detection macro here — example here. Could this macro be enhanced to achieve the same functionality as this pitch?

2 Likes

That macro generates is<case> declarations for each enum case, like this:

@CaseDetection // opt-in macro
enum Destination {
  case inbox
  case messageThread(id: Int)
}

// Generated code:
extension Destination {
  var isInbox: Bool { ... }
  var isMessageThread: Bool { ... }
}

I'm really excited about macros, but there are a few reasons why I think this sort of macro isn't the right solution here:

Even if the macro was defined in the standard library, actually adopting the macro on your enum is op-in, so this would only be usable in cases where the author of the enum declaration opted-in to this functionality.

Other code synthesis functionality like this is typically opt-in (e.g. CaseIterable) but that's because the code synthesis adds a new semantic guarantees that the author needs to consider going forwards. This isn't the case with something like @CaseDetection, since it doesn't impose any additional semantic requirements.

Any solution here would ideally be supported by default for all enum declarations, so users have a consistent way of working with all enum values across their entire project. But even if we could force this macro to be applied to all enum declarations, I don't think we would want to -- the generated properties would result in a large code size increase.

This is why an operator at the call site, like <expr> is case <pattern>, makes the most sense to me.

That being said, I could imagine using a macro defined in the standard library that we use in place of an is case operator. For example:

let destination = Destination.messageThread(id: 42)
print(@isCase(destination, .inbox)) // false
print(@isCase(destination, .messageThread)) // true

// Generates:

let destination = Destination.messageThread(id: 42)
print({ if case .inbox = destination { true } else { false }}()) // false
print({ if case .messageThread = destination { true } else { false }}()) // true

That's definitely a neat option with a majority of the benefits of defining a new operator. I find this significantly less idiomatic than an operator, though, particularly since it's a free function.

1 Like

I want to address these points, because I think it is really important. They are both appeals to consistency: “We already use this spelling for a similar thing, so we should use it for this new thing.” There have been a few other comments along the same lines, not to mention the pitch itself. And I want to push back quite strongly:

  Consistent use of a bad design, is bad.

To quote a member of the language workgroup:

In my view, the existing if case syntax is bad. Really bad. It is incomprehensible to beginners, hard to remember for intermediates, and even experts I think will agree that it inverts the subject and object, thereby hampering code completion. It certainly does not make sense as an English sentence.

By contrast, matches reads fluently.


I think the best way forward is to change the current proposal to use matches, and either,

a. Extend this proposal to allow variable binding in if ... matches statements.
    or
b. Plan on having a future proposal to allow it.

Either way, the destination would be a place where, instead of if case, people can write:

if foo matches .bar(let x) {
  // x is usable here
}

At that point, if case would be entirely superseded, matches would be the pattern-matching keyword that everyone knows and uses consistently, and the language would be much better for it. We would have clarity and consistency, plus code completion would work in patterns.

If we’re going to make progress in this area, we should make progress in that direction.

5 Likes

I don't disagree, but I also don't really feel like the issues with if case are a result of the use of the word 'case.'

12 Likes

I think your argument in favor of using matches instead of is case is decently convincing and I can definitely support that spelling. I don't think the new syntax should support bindings though. Here's a relevant exert from the proposal document, using matches instead of is case:


These expressions could only support bindings in a very narrow context:

// We can't support bindings in general, since there isn't a scope to bind the new variables in:
HeaderView(inThread: destination matches .messageThread(let userId))

// In theory we could support bindings in if conditions:
if destination matches .messageThread(let id) {
  // Do something with `id` here
}

// But this doesn't work when combining `is case` / `matches` expressions with other boolean operators:
if !(destination matches .messageThread(let id)) {
  // `destination` is definitely not `.messageThread`, so we can't bind `id`
}

if destination matches .messageThread(let id) || destination matches .inbox {
  // `destination` may not be `.messageThread`, so we can't bind `id`
}

It would be confusing and inconsistent for these expressions to support different functionality depending on the context.

This functionality is already supported by if case statements. Love them or hate them, I think already having an exactly-equivalent language feature for doing this means it wouldn't be worth it for us to make this extremely narrow carve out in the new is case / matches expression type.

1 Like

Right, but the argument-for-consistency is attempting to build on a broken foundation. We should repair the foundational issue. That is, we should replace if case with a better syntax. (I understand we can’t actually remove if case from the language, but we can make it superfluous, provide automatic migration, and discourage its use.)

One might suggest “if foo is case .bar(let x)” rather than “if foo matches .bar(let x)”. That is an improvement over if case, but I would still say that “matches” is a better fit for pattern-matching, and “is case” remains esoteric. When new people are learning, and when code is being read, “x matches y” actually describes what’s going on. It makes a lot more sense than “x is case y”. After all, the operation is pattern matching, and emphatically not case-comparison.

I strongly disagree. Consistently using a good syntax, which reads well and describes what is happening, is good. Someone who has learned “foo matches .bar” as a boolean expression, will easily understand “if foo matches .bar(let x)”. Consistency serves clarity here, as the wording is clear and consistent.

I see it differently. Because if case is such bad syntax, we should replace it with if ... matches regardless of whether the current pitch is accepted. The fact that this pitch can be made consistent with that, so that matches is used in boolean expressions as well, is a benefit.

Put another way, if the existing spelling were already if ... matches, then surely the current pitch would have chosen matches as its keyword from the outset.

2 Likes

To be clear, you’re suggesting that this pitch be blocked on overhauling the if case syntax throughout the language?

No.

I am suggesting 2 things:

1. This pitch should change to use matches.

2. if case should be superseded by if ... matches.

Whether (2) happens as part of this pitch, or as a separate pitch, is not particularly important.

2 Likes