Comparing Equatable using Opened Existentials
February 20, 2024If you’ve been in Swift ecosystem for many years then you at least encountered this error once: “Protocol ‘XX’ can only be used as a generic constraint because it has Self or associated type requirements”. Maybe you even had nightmares about it 👻!
It’s indeed one of the most common issue developers face while learning the language. And until not so long ago, it was impossible to “fix”: you had to rethink your code by avoiding casting an object to an existential.
Thankfully this time is now over! Let’s acclaim our saviour: any
. We’ll dive into a real usecase (comparing two Equatable
objects) to understand how it can be used to solve our issues.
The Swift Programming Guide defines an existential as such: “A boxed protocol type is also sometimes called an existential type, which comes from the phrase ‘there exists a type T such that T conforms to the protocol’”. In other words: we talk about existential when trying to use a protocol as a concrete type (function signature, casting, …).
Equatable
Let’s create a enum with associated value that we’d like to conform to Equatable
.
enum ServiceState: Equatable {
case failed(Error? = nil)
}
While in most cases Swift would be able to automatically synthesize the requirement, Error
not being Equatable
prevents default implementation from being generated 🥲. So we’ll have to do it by hand:
enum ServiceState: Equatable {
case failed(Error? = nil)
static func == (lhs: ServiceState, rhs: ServiceState) -> Bool {
switch(lhs, rhs) {
case (.failed(let lhsError), .failed(let rhsError)):
if let lhsError = lhsError as? Equatable, let rhsError = rhsError as? Equatable {
return lhsError == rhsError
}
return false
}
}
}
This code won’t be compile though. On old Swift version you’ll get the infamous “Protocol ‘Equatable’ can only be used as a generic constraint” message. But starting with Swift 5.6 you’ll get another one: “Use of protocol 'Equatable' as a type must be written 'any Equatable’”.
That’s interesting! Let’s just follow Swift recommendation:
if let lhsError = lhsError as? any Equatable, let rhsError = rhsError as? any Equatable {
return lhsError == rhsError
}
But that doesn’t compile either. Now you’ll have “Binary operator '==' cannot be applied to two 'any Equatable' operands” error 😖.
But that make sense. Equatable
is expecting to receive two objects whose types are identical. Here we have two Equatable
objects… but they might still be of different types! How to solve this 🧐? You got it: but using opened existentials!
Open Sesame!
The idea of opened existential is to… open an existential 🤷♂️. In other words: gaining access to its internal type (Self
). And what better candidate to access Self
than… the type itself? Sounds confusing but it's actually pretty straightfoward:
- You add a function to your type (protocol)
- Inside this function you use
Self
Let’s apply this to our example:
- We define a function
Equatable.isEqual
- We use it to access
Self
which is the object concrete type - We compare it to our parameter (
rhs
) - We use the new function instead of
==
extension Equatable {
fileprivate func isEqual(_ rhs: any Equatable) -> Bool {
// A. here we can access Self and check if rhs has the same type than ours
if let rhs = rhs as? Self, rhs == self {
return true
}
return false
}
}
enum ServiceState: Equatable {
case failed(Error? = nil)
static func == (lhs: ServiceState, rhs: ServiceState) -> Bool {
switch(lhs, rhs) {
case (.failed(let lhsError), .failed(let rhsError)):
if let lhsError = lhsError as? any Equatable, let rhsError = rhsError as? any Equatable {
// B. now we use our `isEqual` function instead of `==`
return lhsError.isEqual(rhsError)
}
return false
}
}
}
And now this compiles 🎉!
But this code may look a little bit weird to you as we moved the logic inside the protocol. One other option is to use Implicitly Opened Existentials.
Implicitly Opened Existentials
We went from existentials > opened existentials > implicitly opened existentials! I know what you’re thinking: it starts to look like Dragon Ball 🐉 transformations! But I promise: it stops there 😄
Swift 5.7 introduced generic support with existentials: now you can pass an existential as a function generic parameter.
Let’s take back our example and this time instead of extending our protocol we’ll just create a generic function checkIsEqual
:
func checkIsEqual<A: Equatable, B: Equatable>(_ a: A, _ b: B) -> Bool {
if let b = b as? A, b == a {
return true
}
return false
}
enum ServiceState: Equatable {
case failed(Error? = nil)
static func == (lhs: ServiceState, rhs: ServiceState) -> Bool {
switch(lhs, rhs) {
case (.failed(let lhsError), .failed(let rhsError)):
if let lhsError = lhsError as? Equatable, let rhsError = rhsError as? Equatable {
return checkIsEqual(lhsError, rhsError)
}
return false
}
}
}
Here you see that we got implicit access to our two existential types throught A
and B
. Hence the name of this functionality.
Conclusion
You now know how to compare two equatable objects in Swift and better understand how to leverage any
in your codebase and how helpful it can be. Whether you prefer using solution 1 (extending protocol) or solution 2 (implicitly opened existential) will mostly be a matter of taste.