Skip to Content

Equatable on Enum Associated Values

Posted on 4 mins read

The problem

While I was reading Twitter, I came across this good question from @Cocoanetics:


Most of responses were pure technical (but still valid) answers. In short:

you can use a switch to have clearer code, but you still need the same logic

Maybe. But the logic above feels wrong to me because it tries to make two different cases equatable just because they have the same associated value. That’s semantically incorrect. With a such implementation the following code would return true

1.afterID(42) == .beforeID(42)

No difficulty here to understand that this can become a source of bugs. Especially with indirect uses of Equatable, like a Collection’s contains() func for example.

How could we make a more elegant implementation of Equatable with this two requirements:

  • 2 different cases can’t be equal
  • .afterID and .beforeID associated identifiers equality must be easy and straightforward to check.

A simple solution

An easy first attempt could be to perform a strict equality check on cases, and use an identifier property to check identifiers equality:

 1enum QueryFilter {
 2  case noFilter
 3  case afterID(Int)
 4  case beforeID(Int)
 5  case offset(Int)
 6
 7  var identifier: Int? {
 8    switch self {
 9    case .afterID(let id), .beforeID(let id):
10      return id
11    case .noFilter, .offset:
12      return nil
13  }
14}
 1extension QueryFilter: Equatable {
 2  static func == (lhs: QueryFilter, rhs: QueryFilter) -> Bool {
 3    switch (lhs, rhs) {
 4    case (.noFilter, .noFilter):
 5      return true
 6
 7    case let (.afterID(_id), .afterID(id)):
 8      return _id == id
 9
10    case let (.beforeID(_id), .beforeID(id)):
11      return _id == id
12
13    case let (.offset(_offset), .offset(offset)):
14      return _offset == offset
15
16    default:
17      return false
18    }
19  }
20}

Here how it works:

 1QueryFilter.afterID(42) == QueryFilter.afterID(42)
 2// true
 3
 4QueryFilter.afterID(42) == QueryFilter.afterID(1337)
 5// false, identifiers are different
 6
 7QueryFilter.afterID(42) == QueryFilter.beforeID(42)
 8// false, cases are different
 9
10QueryFilter.afterID(42).identifier == QueryFilter.beforeID(42).identifier
11// true, we don't care about the case, we just compare identifiers

But the problem is that it can lead to strange results:

1QueryFilter.noFilter.identifier == QueryFilter.offset(42).identifier
2// true, nil == nil is true 😒

Can we fix this?
Yes. Have you ever heard about indirect enums? 😏


An elegant solution

An indirect case allows you to make a “recursive” enum by associating a case to another case of the same enum.

But how this could solve our problem? It’s quite simple: instead of having identifier being an Int?, this var can be itself a QueryFilter with an indirect case shadowing the original QueryFilter case.

This way, we can make a specific rule for shadowed cases in our == implementation.

 1enum QueryFilter {
 2  case noFilter
 3  case afterID(Int)
 4  case beforeID(Int)
 5  case offset(Int)
 6
 7  // This will shadow one of above cases
 8  indirect case value(QueryFilter)
 9
10  var value: QueryFilter {
11     // Avoid `.value(_)` shadowing
12    if case .value(_) = self { return self }
13
14    // Shadow original case (self) in an `value(_)` case
15    return .value(self)
16  }
17}
 1extension QueryFilter: Equatable {
 2  static func == (lhs: QueryFilter, rhs: QueryFilter) -> Bool {
 3    switch (lhs, rhs) {
 4
 5    // Nothing change for basic cases. We make a strict equality check
 6    case (.noFilter, .noFilter):
 7      return true
 8
 9    case let (.afterID(_id), .afterID(id)):
10      return _id == id
11
12    case let (.beforeID(_id), .beforeID(id)):
13      return _id == id
14
15    case let (.offset(_offset), .offset(offset)):
16      return _offset == offset
17
18    // But we allow comparison between .beforeID(_) and .afterID(_) values
19    // if they shadowed by a .value(_) case
20
21    case let (.value(lhs), .value(rhs)):
22
23      switch (lhs.original, rhs.original) {
24
25      case let (.beforeID(_id), .afterID(id)):
26        return _id == id
27
28      case let (.afterID(_id), .beforeID(id)):
29        return _id == id
30
31      // If it's not a comparison between .beforeID(_) and .afterID(_)
32      // we fallback on the classic equality check.
33
34      default:
35        return lhs == rhs
36      }
37
38    default:
39      return false
40    }
41  }
42
43  // This recursively get the original shadowed value even if you do somwthing like :
44  // let query = QueryFilter.value(.value(.value(.afterID(42))))
45  private var original: QueryFilter {
46    if case let .value(queryFilter) = self {
47      return queryFilter.original
48    }
49    return self
50  }
51}

Does it work well? Hell yes! 😈

 1QueryFilter.noFilter == QueryFilter.offset(42)
 2// false
 3
 4QueryFilter.afterID(42) == QueryFilter.afterID(42)
 5// true
 6
 7QueryFilter.afterID(42) == QueryFilter.afterID(1337)
 8// false
 9
10QueryFilter.afterID(42) == QueryFilter.beforeID(42)
11// false
12
13QueryFilter.noFilter.value == QueryFilter.offset(42).value
14// false
15
16QueryFilter.afterID(42).value == QueryFilter.afterID(42).value
17// true
18
19QueryFilter.afterID(42).value == QueryFilter.beforeID(42).value
20// true

This post was initially posted on medium

comments powered by Disqus