[Pitch] is case expressions

Following @jrose 's draft proposal, @cal and I took a stab at fleshing out this pitch (below and also linked here). A draft implementation is available here.

is case expressions

Introduction

It's often useful to check whether or not an enum matches a specific case. This is trivial for simple enums without associated values, but is not well-supported today for enum cases with associated values.

We should introduce a new is case expression that lets you evaluate the result of any pattern matching an expression in a way similar to a switch or if case statement:

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

let destination = Destination.messageThread(id: 42)
print(destination is case .inbox) // false
print(destination is case .messageThread) // true
print(destination is case .messageThread(id: 0)) // false
print(destination is case .messageThread(id: 42)) // true

// SwiftUI view
VStack {
  HeaderView(inThread: destination is case .messageThread)
  ...
}

Motivation

It's often useful to check whether or not an enum matches a specific case. This is trivial for simple enums without associated values, which automatically conform to Equatable:

enum Destination {
  case inbox
  case messageThread
}

let destination = Destination.messageThread
print(destination == .messageThread)

After adding an associated value, you can no longer use value equality for this:

enum Destination: Equatable {
  case inbox
  case messageThread(id: Int)
}

let destination = Destination.messageThread(id: 42)
// error: member 'messageThread(id:)' expects argument of type 'Int'
print(destination == .messageThread)

For enums with associated values, the only way to implement this check is by using an if / switch statement. One may assume that Swift 5.9's support for if / switch expressions would be a suitable way to implement this check, but those expressions cannot be written in-line:

// error: 'if' may only be used as expression in return, throw, or as the source of an assignment
// Even if this was allowed, it would be pretty verbose.
HeaderView(inThread: if case .messageThread = destination { true } else { false })

Instead, the result of this check must be either assigned to a variable or defined in a helper:

let isMessageThread = if case .messageThread = destination { true } else { false }

// or:

let isMessageThread = switch destination {
  case .messageThread: true
  default: false
}

HeaderView(inThread: isMessageThread)

Checking whether or not a value matches a pattern is already "truthy", so the extra ceremony mapping the result of this condition to a boolean is semantically redundant. This syntax is also quite verbose, and can't be written inline at the point of use.

This problem is such a pain-point that some have even recommended mirroring with a parallel enum that has no associated values in order to benefit from direct equality-checking:

enum Destination: Equatable {
  case inbox
  case messageThread(id: Int)

  enum Case {
    case inbox
    case messageThread
  }

  var `case`: Case {
    switch self {
    case .inbox: .inbox
    case .messageThread: .messageThread
    }
  }
}

These ad-hoc solutions are non-trivial to maintain and place the burden of keeping them up-to-date on the author.

Instead, we propose adding new type of expression, <expr> is case <pattern>, that evaluates to true or false based on whether <expr> matches <pattern>. That would allow us to write this sort of check inline and succinctly:

HeaderView(inThread: destination is case .messageThread)

Detailed Design

The following expression type would be added to the language grammar:

infix-expression -> is case <pattern>

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

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

Unlike if / switch expressions added in Swift 5.9, if case expressions would be usable anywhere you can write an expression.

The expression would support matching any type of pattern that can be used in a switch statement:

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

But since these expression are not part of a control flow structure, they won't support binding associated values to variables. For example, the following usage would not be allowed:

// Not allowed, since there isn't a new scope where the bound property would be available
foo is case .bar(let value)
MessagesView(inThread: screen is case .messageThread(let userId))

This syntax can also be extended by overloading the ~= operator just as in within switch and if case statements.

At face value this seems like it would create the opportunity for some "silly" conditions like foo is case 42. Despite being a bit silly, these conditions are harmless and are important to support for two reasons:

  1. maintaining feature parity with case <pattern> in switch statements is important to ensure a consistent mental model of how pattern matching works in Swift

  2. very similar spellings are already possible today in conditions like if case 42 = foo { true } else { false }

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.

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

Do nothing

As of Swift 5.9 (SE-390), you can implement this with if case <pattern> = <expr> { true } else { false }. These conditions are verbose and cannot be written in-line in other expressions, so are not a sufficient replacement for is case expressions.

Similar variants of these types of syntax can also coexist. For example, Rust provides a matches! macro in its standard library even though it also supports control flow expressions:

#[derive(Copy, Clone)]
enum Destination {
  Inbox,
  MessageThread { id: i64 },
}

fn main() {
  let destination = Destination::MessageThread { id: 42 };
  // Analogous to proposed `screen is case .messageThread` syntax
  println!("{}", matches!(destination, MessageThread)); // prints "true"
  // Analogous to if / switch expression syntax, but can be used in-line
  println!("{}", if let MessageThread = destination { true } else { false }); // prints "true"
}

Allow variable bindings

If we lifted the restriction on variable bindings, it would be possible to check against an enum case and bind its associated value to a local scope in single expression, such as:

if destination is case .messageThread(let id) {
  // Do something with `id` here
}

This would effectively be an alternative spelling of the existing if case syntax:

if case .messageThread(let id) = destination {
  // Do something with `id` here
}

Using is case syntax in this way is potentially an improvement over if case syntax, since if case syntax is well-known for having poor autocomplete support. Despite this, there are several downsides to an approach like this.

Most importantly, is case 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 is case .messageThread(let userId))

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

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

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

It would be confusing and inconsistent for is case expressions to support different functionality depending on the context. It would also be less-than-ideal to have two separate spellings of the exact same feature. Since this functionality is already supported by if case syntax, we don't need to support it here.

Case-specific computed properties

Another approach could be to synthesize computed properties for each enum case, either using compiler code synthesis or a macro.

For example, for case foo(bar: Int, baz: Int) we 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 / partial values.

There are also some key drawbacks to an approach like this:

  1. Automatically synthesizing these properties for every enum case would result in a large code size increase, so we likely wouldn't want to enable this by default.

  2. If this is not enabled by default, then this would only be useful in cases where the owner of the enum declaration opted-in to this functionality. Since this doesn't impose any additional semantic requirements on the author of the enum declaration (e.g. like with CaseIterable), there aren't any semantic benefits to making this opt-in.

Alternative spellings

Some potential alternative spellings for this feature include:

// case <pattern> = <expr>
// Consistent with the existing `if case`, but not evocative of a boolean condition.
HeaderView(inThread: case .messageThread = destination)

// <expr> case <pattern>
// Not evocative of a boolean condition
HeaderView(inThread: destination case .messageThread)

// <expr> is <pattern>
// Less clearly related to pattern matching (always indicated by `case` elsewhere in the language)
HeaderView(inThread: destination is .messageThread)

// <expr> == <pattern>
// Special case support for a specific operator.
// Could be confusing to overload == with multiple different types of conditions.
// Ambiguous for enum cases without assoicated values (which equality codepath would it use?).
HeaderView(inThread: destination == .messageThread(_))

Of these spellings, <expr> is case <pattern> is the best because:

  1. it's clearly a condition that evaluates to a boolean

  2. it includes the keyword case to indicate its relationship with existing pattern matching syntax (switch cases, if case)

  3. it doesn't introduce conflicts or ambiguity with existing language features

Acknowledgments

Andrew Bennett was the first person who suggested 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 we missed as well, and this isn't even counting "Using Swift" threads!)

Jon Hull (2018), among others, for related discussion on restructuring if case.

46 Likes

I am strongly supportive of addressing this.

I do have a question though. The pitch states:

And then among the examples it gives:

Now, if we make some sample code:

enum Pet: Equatable {
  case dog(name: String)
  case cat(lives: Int)
}

func compare(_ x: Pet, _ y: Pet) -> Bool {
  switch x {
  case y: return true
  default: return false
  }
}

And test it like this:

let a = Pet.dog(name: "Fido")
let b = Pet.dog(name: "Snoopy")
let c = Pet.cat(lives: 9)

print(compare(a, a), compare(a, b), compare(a, c))    // T F F
print(compare(b, a), compare(b, b), compare(b, c))    // F T F
print(compare(c, a), compare(c, b), compare(c, c))    // F F T

Then the output is the identity matrix. In particular, the switch statement does not match two dogs with different names.

Based on the general goals of the pitch, I would expect that two variables which store the same case of an enum, but with different associated values, should have is case evaluate to true. However, the current text of the proposal does not appear to align with that.

Could we make the text more clear in this regard?

(I am hopeful that the intention is to match the cases regardless of associated values, because otherwise the workaround of a nested simple enum would still be necessary.)

1 Like

This is not correct. is case takes a pattern, expressions are a kind of pattern, and matching expressions is done by calling ~=. Not allowing that would break other kinds of matching that switch can do.

I agree that sometimes “are these the same case” is useful, but this pitch isn’t intended to solve that.

5 Likes

…what is not correct?

The statement about what I would expect, is indeed correct. I do expect that a is case b should only compare the cases.

And the statement about what the proposal appears to say, is also correct. The proposal does appear to say that a is case b compares the associated values as well as the cases.


The current pitch would make is case take a pattern.

Whether or not that is the optimal design, is up for discussion here.

For that matter, the very use of the spelling “is case” is also up for discussion.


The way I see it, we already have excellent facilities for checking whether two variables hold the same value. Namely, the == operator from the Equatable protocol.

And we also have quite good facilities for checking whether a variable holds a value of a statically known enum case. Namely, if case .foo = x. Now it is true that does read a bit more awkwardly than if x is case .foo, but the difference is fairly minor.

What we do not currently have, is a good way to check whether two variables hold values of the same enum case. That is where the workaround of introducing an entirely new enum, manually duplicating all the cases of the original but eschewing associated values, and implementing a case property comes into play.

Another alternative would be a giant switch over both variables with a line for every case:

switch (x, y) {
case (.a, .a): return true
case (.b, .b): return true
...
default: return false
}

Either way, the language does not currently provide a good way to test two variables for case-equality while ignoring associated values. It does provide good ways to perform all the other tests under discussion.

Therefore, in my opinion, the most important thing to address in this space is the very one which the current pitch does not address at all. Namely, to check whether two variables hold the same enum case.

3 Likes

We clearly need this functionality, it’s one of the most glaring omissions in Swift I think. But I would strongly favour seeing case as an operator that converts an enum case to its discriminant, and using == instead of is:

let isOpen = value == case .open

This would also allow us to write !=.

Furthermore, is is normally used to check what type something is, which is quite different from this.

2 Likes

How would this work when two cases have the same name ?

1 Like

Yes!!! :clap: Ever since @jrose suggested this there have been so many times when I’ve thought ”this would have been so easy if we just had is case.”

2 Likes

Agree, this is a hole in Swift worth filling.

We should also consider whether we do a partial match:

enum Destination {
  case inbox
  case messageThread(id: Int, other: Int)
}

destination is case .messageThread(id: 42, other: _)

In other words would there be a shorthand equivalent to this:

{ if case .messageThread(id: 42, other: _) = destination { return true } else { return false }}()

Alternatively, we could modify (simplify) swift syntax and treat

case .messageThread(id: 42, other: _) = destination

or

case .messageThread = destination

or possibly:

destination = case .messageThread

as a normal boolean expression – so it works not just in "if" contexts but in other places as well – it would be very similar to yours:

destination is case .messageThread
2 Likes

As a maintainer of Case Paths, I'm a huge advocate for advancing the language's capabilities and ergonomics when it comes to enums, and this pitch definitely shines a bright light on a shortcoming. Thanks for working on a fix!

While this syntax would be really useful right now, and works great for this use case, there are adjacent, more general use cases that this syntax doesn’t seem to naturally extend to, and there are really cool things one could do with enums that might be completely foreign to this syntax. So my main concern is that this syntax has the potential to box us into a choice that makes it difficult to explore these other operations, abstractions, and tools for enums.

And so in order to better evaluate the proposal, I think it'd be helpful to have more of a vision of how Swift intends to address tangential shortcomings. While this pitch aims to solve expressive case checking, there is then the obvious next step of expressive case extraction (which comes up a lot on these forums and elsewhere).

The pitch does mention why inline extraction is not allowed:

// Not allowed, since there isn't a new scope where the bound property
// would be available
MessagesView(inThread: screen is case .messageThread(let userId))

And also has an "Allow variable bindings" alternative considered, where it only theorizes a statement-driven alternative spelling to if case.

I think there's another alternative to consider, though, and that is the ability to extract an enum payload without introducing a new scope, and we can do so by making the extracted value optional. Ideally Swift will eventually provide a way to optionally extract an enum payload as an expression, just as this proposal provides a way to check an enum case as an expression.

What such a syntax would look like is certainly up for debate, and whether or not the syntax of this pitch should influence it. I could see taking the pitch's is and trying to use as (naively):

let id/*: Int?*/ = destination as case .messageThread

(screen as case .messageThread).map(MessagesView.init(inThread:))

let ids: [Int] = destinations
  .compactMap { $0 as case .messageThread }

This doesn't quite "work" as nicely as the pitch, though, since it is less reliant on existing syntax and functionality, but this is at least enough to start bike-shedding.

Then we can continue the thought experiment and ask: what does it looks like to chain from these case expressions?

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

And then the question is how these "case paths" might be constructed:

\Destination.as case .messageThread

// Or would parentheses be required?
\Destination.(as case .messageThread)

And how they might compose with key paths:

\Model.destination.(as case .messageThread)
// KeyPath<Model, Int?>

\Result<User, Error>.(as case .success)?.name
// KeyPath<Result, String?>

The further along we get, the harder I find it to extend this syntax.

The thing it seems like we're looking to solve is to extend the ergonomics or property dot-syntax to enum cases. This brings us to another alternative mentioned, which is "Case-specific computed properties." This alternative is dismissed due to code synthesis size, but it's unclear why the language couldn't support dot-syntax for case payload access without synthesizing property declarations. This syntax could be made automatically available to enum cases without any change to existing code size.

Dot syntax seems like a natural way to access both properties and case payloads, and avoids the messiness and parentheses introduced by the spaces in as case. To rewrite the examples from above:

let id/*: Int?*/ = destination.messageThread

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

let ids: [Int] = destinations.compactMap(\.messageThread)

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

// No parentheses required:
\Destination.messageThread

\Model.destination.messageThread
// KeyPath<Model, Int?>

\Result<User, Error>.success?.name
// KeyPath<Result, String?>

Dot syntax also makes things like code completion a lot nicer.

If we had this more general extraction functionality, we get the "case checking" mechanism this pitch proposes for free:

// Instead of `destination is case .messageThread`:
destination.messageThread != nil // true

And then the question is: if we are given such a syntax in the future, would we still want the additional surface area of is case?

There are certainly other alternatives to consider, but I guess that's my point, that this kind of discussion should ideally happen before we add piecemeal surface area that makes this cheatsheet even longer.

There’s a lot more to consider when it comes to closing the gap between structs and enums (case wrappers, dynamic case lookup, computed cases, etc.). I wish someone on the Swift team had time to write up an “Enum Manifesto” that could dive deep into these potential future directions! It would make it a lot easier to evaluate smaller pitches like this one.

34 Likes

I remember something similar was suggested before:

enum Destination {
  case foo
  case bar(id: Int)
  case baz(id: int, other: String)
}

var v: Destination = ...

v.foo // type is inferred as `()!`
      // value is either nil or ()
      // usage example: if v.foo != nil { ... }

v.bar // type is inferred as (id: Int)! ideally (once we have one-element tuples).
      // before that it could be inferred as `Int!`
      // value is either nil or some int value

v.baz // type is inferred as a tuple `(id: Int, other: String)!`.
      // value is either nil or (id: Int, other: String)


Note, however

neither this nor what you are suggesting supports a partial matching with, say,

if value is case .baz(id: 42, other: _)

I am not saying we necessarily need that, just pointing out the difference.

1 Like

Wouldn't this be the equivalent?

if value.baz?.id == 42
1 Like

Perhaps. But this?

if value is case .baz(a: 1, b: 2, c: 3, d: 4, e: _) { ... }

I use this pattern quite often, but every time I do it, I torment myself by asking Why am I using the assignment operator instead of the equality operator?

3 Likes

I might need a more concrete example motivating the use of an expression instead of a statement, since this if statement is equivalent to the following syntax that already exists:

if case .baz(a: 1, b: 2, c: 3, d: 4, e: _) = value { ... }

In any case, compound partial matching is rare enough that I think we'd still ask: is it worth the additional is case syntax to support on top of dot-syntax?

Especially when you could also express this:

value.baz.map { a, b, c, d, _ in (a, b, c, d) == (1, 2, 3, 4) }
1 Like

The syntax that already exists is pretty widely recognized as doubleplus ungood.

2 Likes

Ha that might be the case, but is case isn't being pitched as a replacement to the existing syntax. And in this example the existing syntax is basically the same as the proposed syntax (if not slightly shorter):

if value is case .baz(a: 1, b: 2, c: 3, d: 4, e: _) { ... }
// vs.
if case .baz(a: 1, b: 2, c: 3, d: 4, e: _) = value { ... }

If it were being pitched as a replacement to all pattern matching that doesn't have let bindings, that's all the more reason to figure out a wholesale alternative that supports the more general use case of extraction.

2 Likes

I think these examples kind of overlook the point because you're just swapping an affirmative pattern in an if statement for an affirmative expression in that statement. But the power of is case is that it's usable in any expression context:

fn(someBool: value is case .baz(a: 1, b: 2, c: 3, d: 4, e: _))

But even in if statements, negation becomes a lot cleaner because you can't negate a pattern. You can't just use if case to look for "all patterns but something" without a pointless empty block, while the expression makes it straightforward:

if !(value is case .baz(a: 1, b: 2, c: 3, d: 4, e: _)) {
  ...
}

As someone who's personally not a fan of the idea of automatically adding case-based computed properties to any enum, this definitely fills a usability gap that I've hit before. I don't hit it super frequently, but when I do, the workarounds are uglier than this would be.

5 Likes

In my original reply I specify that I don't think we should automatically add synthesized computed properties to enums, but instead the compiler should support dot syntax to "open" an enum's associated value. It's not even possible to define a computed property right now, given that the functionality we require is an optional get with a non-optional set (the same behavior as optional chaining).

I also mention in my reply that dot syntax is just one example (after a thought experiment of following the pitch to a potential as case), though I do think that dot syntax is the easiest to motivate when you consider the composition of property access and case access, and the existence of optional-chaining, which is basically dot syntax for case access (limited to the Optional.some case). If you have reasons to de-motivate dot syntax, or can suggest an alternative syntax, I'd love to hear it.

Sure, that could be achieved with a more general extraction syntax:

fn(someBool: value.baz.map { a, b, c, d, _ in (a, b, c, d) == (1, 2, 3, 4) } ?? false)
2 Likes

I don't want to get caught up in the semantics of "it's not actually a computed property" because it's the effects on the exposed API that I really care about. I'm not opposed to some ability for types to opt in to having some kind of accessors to make dotting into a case's payload possible, if that's the API they want to offer. I've written them by hand myself and thought "I wish I could synthesize this." But I don't want all enum types to carry these "things that look like properties" as part of their public API unless they're requested by the author.

Considering how much Swift embraces patterns for matching and extraction, I personally find this to be a much worse alternative in both readability and writability. It requires much more cognitive overhead to parse what's going on with the caseName.map pattern and having to either name the closure arguments or use $0, $1, .... On the other hand, is case does exactly what it says on the tin. It's much easier to teach and to understand when you see it.

Generality is a good goal if said generality simplifies a problem, but in this case, I think it does the opposite.

3 Likes

Partial matches such as destination is case .messageThread(id: 42, other: _) would be valid under the current pitch as we'd support all patterns excluding those that include variable binding.

1 Like