Comparing enums without their associated values

It would be nice if you could compare an enum without its associated values i.e a case equality check only rather than case & associated value equality check.

For example:

enum Foo {
    case bar(Int, String)
}

let test: Foo = .bar(1, "Hello")

let isEqualWithItsAssocValues = test == .bar(1, "Hello") // true
let isEqualWithoutItsAssocValues = test == .bar(_, _) // also true

This makes it really easy to compare enums if you only care about the case and not its associated values and you wouldn't have to write your own equatable conformance, which could lead to problems if you want to compare with the associated values.

If the compiler could synthesise this or allow comparison without associated values then it would be helpful.

For example, a protocol like CaseEquatable perhaps?

9 Likes

In many places (if, guard, switch, etc.) you can already do this:

if case .bar = test {
    // ...
}

But it would be nice to have a shorter way of assigning the result or passing it to a function parameter, because this is tedious:

let isEqualWithoutItsAssocValues = { if case .bar = test { return true } else { return false } }()

The idea of synthesized properties of the form var isBar: Bool has also come up. That is another way of solving the same problem:

let isEqualWithoutItsAssocValues = test.isBar
2 Likes

You could manually create a nested enum without associated values:

enum Foo {
  case bar(Int)
  case baz(String)
  
  var `case`: Case {
    switch self {
    case .bar: return .bar
    case .baz: return .baz
    }
  }
  
  enum Case {
    case bar
    case baz
  }
}
4 Likes

Linking an older thread on this topic.

2 Likes

Yes, I use the "duplicate but payloadless enum thing" occasionally. It's ugly and verbose, but wow can it ever make life easier. Swift should give us a way to implement that code for us.

10 Likes

Using the is-Syntax may be a another idea:

if test is .bar {
  // ...
}
12 Likes

I have a more radical stance on this: I believe associated values currently break enums in Swift.

Enums should be about comparing cases; looking up associated values goes one step beyond.

For instance, I use an enum like this in my code:

enum Marsupial {
    case koala
    case kangaroo
    case wombat
    case other
}

So in addition to switch blocks, my code is full of lines checking for individual cases like this:

if theAnimal == .koala {...

Suppose I want to add the possibility to store the name in the "other" case:

enum Marsupial {
    case koala
    case kangaroo
    case wombat
    case other (name: String?)
}

Suddenly, my enum is broken. Equality comparisons are out of the window, and I have to use some of the least intuitive syntax I've ever seen in Swift:

if case .kangaroo = theAnimal {

I now have to play with the Equatable protocol to at least match the cases other than other, and/or come up with computed properties of the enum like isKangaroo and isOther… While I would actually still expect the following to work:

if theAnimal == .kangaroo || theAnimal == .other { ...

Enums are about comparing cases. If we also want to look into associated values, let's use the let syntax:

if case let .other(animalName) = theAnimal, case let .other(beastName) = theBeast, animalName == beastName { ...

This may be complex, but IMO it is more appropriate to be complex than comparisons ignoring associated values, which should be simple, remaining true to the nature of an enum.

6 Likes

The main point of my frustration is that not everything in switch-case pair can be translated to Bool expression:

switch base {
case pattern1: break
default: break
    print("not pattern 1")
}

// I would expect this pattern matching operator to work,
// but it doesn't if any of the case has associatedType
if !(base ~= pattern1) {
    print("not pattern 1")
}

Of course the main obstacle would be case let pattern, but I believe anyone who wants to bind associatedType wouldn't use pattern matching as boolean expression since it doesn't make sense to bind if case doesn't match.

So we can extend the synthesised pattern matching operator(~=) to cover associatedType without variable binding, either implicitly, or at user's explicit request.

1 Like

How about a synthesised property - caseValue (similar to rawValue). For example:

enum Foo {
  case one
  case two(Int)
}

let test1 = Foo.one
let test2 = Foo.two(3)

let value1 = test1.caseValue // Foo.one
let value2 = test2.caseValue // Foo.two

Could call it case or caseValue - I'm fine either way.

4 Likes

A protocol version might be better:

enum Foo {
	case one
	case two(_ three: Int)
}

protocol CaseEquatable {
	static func ==(lhs: Self, rhs: Self) -> Bool
}

// This could be either synthesised or opt-in like CaseIterable
extension Foo: CaseEquatable {
	static func ==(lhs: Foo, rhs: Foo) -> Bool {
		switch (lhs, rhs) {
		case (.one, .one): return true
		case (.two(_), .two(_)): return true
		default: return false
		}
	}
}

let test1 = Foo.two(1)
let test2 = Foo.two(3)

let isEqual = test1 == test2 // true

A compiler generated caseValue sounds very useful. I'm -1 on a protocol that collides with Equatable however.

3 Likes

I'm not sure if simply stripping away the associatedValue is versatile enough, since switch-case can compare much more. Though this problem appears few and far between for me to say.

Hmm although the problem with caseValue would be that you cannot have two cases with the same name, so it would be more tricky to implement the feature.

With a protocol, it can be avoided, but as you pointed out, it can cause conflict with Equatable. Perhaps it could be a synthesised method on an enum instead of an operator (like isEqual()).

So you can do:

let foo = Foo.a(1)
let bar = Foo.a(2)
let isEqual = foo.isEqual(ignoringAssocValues: bar) // true

(maybe we need to find a better method/param name!)

Note also that there is also some confusing behaviour in the status quo.
~= is not used when you use case pattern between 2 variables of the same type.

enum Test {
    case a, b, c
}

func ~=(_ lhs: Test, _ rhs: Test) -> Bool {
    print("Using Custom Comparison")

    return true
}

print("If match")
if Test.a ~= Test.b { }

print("If case-match")
if case Test.a = Test.b { }

print("Switch case")
switch Test.a {
case .a, .b, .c: break
}

// If match
// Using Custom Comparison
// If case-match
// Switch case

The code above uses custom implementation only in the first cast. Perhaps we can put generated behaviour in ~=, as well as making ~= more in line with other enum comparison.

3 Likes

FWIW, a number of people agree with you on this—except that our conclusion is that enums shouldn't implicitly conform to Equatable at all. Unfortunately that'd be very source-breaking.

1 Like

I think we should do it like @lassejansen suggested: as a built-in, like if case and guard case. I don't think it should be yet another synthesized property or protocol that dumps a bunch of otherwise unneeded names into the type. And like "if case", I think this a general feature all sum types need, not an opt-in.

Since is takes a type on its right side, we need another keyword to avoid complicating the parser:

if myEnumValue cases .enumCase1 { /*...*/ }

Here, I used "cases" as the new operator.

You can already write this:

if case .a = x { … }

The tricky thing is to compare two variables to see if they are the same enum case, ignoring associated values. The natural spelling might be:

if case x = y { … }

Which currently gives an error:

Expression pattern of type 'Foo' cannot match values of type 'Foo'

We can make this work manually by implementing the pattern-matching operator:

extension Foo {
  static func ~= (lhs: Foo, rhs: Foo) -> Bool {
    switch (lhs, rhs) {
    case (.a, .a),
         (.b, .b),
         ...     : return true
    case _: return false
    }
  }
}

One solution would be to have the compiler synthesize this for every enum. However, this might not be ideal.

4 Likes

I encounter this need too often, especially with nested enums. +1

I believe this thread could be merged with this:

(And yes +100)

2 Likes