[Pitch] Proposal for Enhanced Enum Pattern Matching Syntax in Swift

Introduction

Swift's powerful enum feature, especially with associated values, is a cornerstone of the language's type safety and expressiveness. However, the current pattern matching syntax can be cumbersome when using the if case construct, particularly in scenarios where the enum type must be inferred. This proposal suggests a new syntax, if <var> case, to simplify and enhance the pattern matching experience.

The Problem

When using the if case construct, we often face issues with autocompletion and clarity. For example:

if case .qrcode(let value) = code {
    // Process value
}

In this structure, when typing ., Xcode cannot infer the enum type associated with code, leading to a poor autocompletion experience. As a result, we must either explicitly declare the type before the case or rely on trial and error.

Proposed Solution

To address these issues, I propose a new syntax that allows developers to directly reference the variable before the case statement:

if code case .qrcode(let value) {
    // Here, 'value' is available if 'code' is of type .qrcode
}

Advantages of the Proposed Syntax

  1. Improved Clarity: This syntax maintains the logical flow of condition checking while making it clear which variable is being checked against the enum case.

  2. Enhanced Autocompletion: Since code is defined before case, the compiler can infer the type of code, allowing for better autocompletion for associated values.

  3. Reduced Verbosity: This approach eliminates the need for redundant type declarations, resulting in cleaner and more concise code.

  4. Consistency with Language Principles: The proposed syntax aligns with Swift's goals of safety, clarity, and expressiveness, providing a more natural way to perform pattern matching.

Example with guard

The proposed syntax could also be effectively used with guard, allowing for early exits when dealing with enums:

guard code case .qrcode(let value) else {
    // Handle the case where 'code' is not a .qrcode
    return
}
// Proceed with 'value' if 'code' is of type .qrcode

This allows developers to ensure that they only proceed with valid values, maintaining clean and readable code.

Conclusion

The if <var> case syntax presents an opportunity to streamline pattern matching in Swift, enhancing both developer experience and code readability.

16 Likes

I'd like your idea, but I personally prefer is keyword requirement for intuition.
It'd be somthing like:

3 Likes

I think they're complementary, is case would be a boolean expression, but this supports full matching with bindings.

Precise spelling aside, the one drawback I see is that today all the patterns that introduce bindingsā€”other than parametersā€”are on the left side of a statement, beginning just after an introducer (let, var, case, or for, sometimes combined with if, guard, or while). This would break that rule. But it's probably worth it for the code-completion benefits, and it's not actually hard to understand.

8 Likes

I get your point about bindings typically being on the left side, but I think there's a good parallel with how it's already done in switch statements.

For example, in a switch:

switch code {
case .qrcode(let value):
    // Handle value
default:
    break
}

The binding happens with the case itself, similar to what I'm proposing. The idea with if code case .qrcode(let value) is to create something like a one-clause switch but more streamlined for cases where you're only checking one condition.

This keeps the pattern familiar and makes code completion better, without adding much complexity.

8 Likes

I am strongly in favor of an enhancement in this area. I have often been frustrated at the lack of code completion with the current if case syntax. It's not a major issue but it is a very noticeable problem that I would like to see fixed.

I don't have strong feelings on the proposed syntax. It looks fine at a first glance but I would await the to be expected bike shedding before giving a firm opinion.

I would expect this syntax to allow matching just the case if myEnum case .qrcode {} or matching including bindings if myEnum case .qrcode(let value) {} and having a single new addition cover both the is case use case and improving autocompletion would be nice.

3 Likes

Iā€™m generally a fan of the C# pattern-matching, mostly because it reads naturally from left to right the way I would say it.

I think this is definitely a step in the right direction, both for completion and being easier to remember!

It could then naturally ease into ā€œis caseā€ which I would love to have which could naturally ease into other ā€œisā€ patterns.

is case would be ideal to use with Swift Testing:

#expect(x is case .y)

Versus what you need to do today:

if case .y = x {} else {
  Issue.record("x wasn't .y")
}
9 Likes

Having is case usable as both a Boolean expression and as a way to bind patterns in a condition list seems ideal to me. I think we could make this work:

enum E { case a(Int) }
let e = E.a(10)

_ = e is case .a  // true
_ = e is case .a(10)  // true
_ = e is case .a(11)  // false
_ = e is case .a(let x)  // error: cannot bind variables in 'is case'
                         // outside of a control flow statement condition
if e is case .a(let x) {
  print(x)  // "10"
}
// guard, while, etc. are similar

In my experience, when I want to match an enum in a test, I usually want to also bind a payload and write an expectation about that as well. If I have an enum where all I care about is the case discriminator, chances are that's a payload-free enum where I could just make it Equatable and write #expect(x == .y) instead.

I don't know how we would combine #expect with bindings though. If we extended if/switch expressions to be allowed as function arguments, we could at least write this, without any other changes:

#expect(if case .a(let x) = e { x == 10 } else { false })

Then, if is case was added:

#expect(if e is case .a(let x) { x == 10 } else { false })

Still not as concise as I'd like, but an improvement on what we have today.

9 Likes

It would be nice to have a better syntax (as well as key paths for enum cases that @stephencelis and I have pitched a few times), but it is possible to accomplish a lot of this in Swift today. You just need to use macros, which means there are a lot of downsides that come with it (like slow compile times).

But, if you are willing to incur those costs, then we have a library called Case Paths that allows you to write an enum like this:

import CasePaths

@CasePathable
enum Loadable<Value> {
  case loading
  case loaded(Value)
}

ā€¦and then you can check the case of an enum with the is method:

let value = Loadable.loaded(42)
#expect(value.is(\.loaded))

Note that we are using a key path \.loaded to abstractly reference the case of an enum.

And you can extract a value for a case using a [case:] subscript:

#expect(value[case: \.loaded] == 42)

It gives you a very familiar key path syntax, but for the cases of an enum. And it also works for nested enums automatically:

@CasePathable
enum Status { case on, off }

let status = Loadable.loaded(Status.on)
#expect(statue.is(\.loaded.on))

Further, if want computed properties to be automatically added to your enum for each case, then you just have to add @dynamicMemberLookup:

@CasePathable
@dynamicMemberLookup
enum Loadable<Value> {
  case loading
  case loaded(Value)
}

let value = Loadable.loaded(42)
#expect(value.loaded == 42)

It would of course be far better if these features were built into the language, and we would love if Swift sherlocked any of this. But, in the meantime, it is possible to make working with enums and cases more expressive.

14 Likes

(either with or without is between code and case) is better then what we have today. Not only in the autocompletion support, I often find myself searching for "how to do swift if case" no matter how many times I used it already. That the enum variable is at the end in the current syntax (contrary to switch) makes it so weird.

6 Likes

As I was skeptical at the first sight, the need to remember how the case is named (if there is something to remember in the first place) when writing if statement, and autocomplete is basically useless here because variable goes after, the change makes a lot of sense!

Would

#expect(e is case .a(10))

not be possible?

For that particular case, sureā€”perhaps I oversimplified it. Consider something that isn't strict equality of the bound value x so that it can't be written simply as an expression pattern:

guard case let .a(let x) = e else {
  Issue.record("e wasn't .a")
  return
}
#expect(x.someMember == somethingElse)

or even

guard case let .a(let x) = e else {
  Issue.record("e wasn't .a")
  return
}
#expect(x > 10)

Not sure if this would overly complicate things, but what if an is case expression outside of a control flow statement could have bindings but the bindings would only be visible within a trailing where clause:

#expect(e is case .a(let x) where x.someMember == somethingElse)

I don't know how I feel about that, though. The expression grammar is complicated enough as it is...

We've had enough requests for just case-level pattern matching that we'd probably want to support is case if it came to be. But yes, a simple x == .y(z) works today as expected when the type is Equatable.

I think we'd be happy to work with whatever new syntax might be added.

For those who need auto completion, you can always start by writing first the = x part then you have auto completion for dot notation.

If something is to be changed in this area, Iā€™m more inclined to something more general like the direction taken by CasePaths.

2 Likes

I would lean towards more general improvements like @CasePathable is doing.

I'm not using it only because of the Swift Macro's current usage downside.

I'm observing and wishing that at some point CasePathable abilities would finally land directly into the language. It solves a great amount of enum conveniences. Not only is to test case but also, query variables without boilerplate, CasePath and its composability.

3 Likes

I agree that the current syntax has an autocompletion problem.

However, the proposed solution can't solve the same problem in for case.

for case .foo(let value) in array {
    // ...
}
1 Like

Bear with me, I'm just thinking out loud, but I've used the existing syntax and the pointfree macros and thinking at what I'd want from all of this I ended up with something like this:

enum Transportation {
    case bus(passengers: Int)
    case truck(load: Double, towing: Double)
    case train(length: Double)

    // All of the following gets magically generated by the compiler:

    var bus: Int?
    var truck: (Double, Double)?
    var train: Double?

    enum Case: Equatable {
        case bus
        case truck
        case train
    }

    var case: Case // Dunno if this will work that great with `case` being a keyword.
}

This would also allow keypath use and make all of the use cases we're discussing here into common Swift idioms.

1 Like

The language steering group has provided the following feedback to proposed solutions in this space in the past:

1 Like