Equatable Error in Swift
Introduction
Error is an easy to use protocol in Swift. As it can be used as an existential type, you can declare a value of type Error
and pass it around:
func printError(_ error: Error) {
print(error)
}
struct NetworkError: Error {}
struct DatabaseError: Error {}
let error: Error = NetworkError()
printError(error) // prints "NetworkError()"
By using Error
as an existential type, we avoid leaking implementation details. We can pass the error
variable as the Error
protocol to the printError
function. The function does not need to know the underlying concrete Error
type. The code becomes more decoupled and versatile since it can be used with any concrete error conforming to the protocol. This is basically what abstraction is all about.
Sometimes we need errors to be Equatable
; unfortunately, Equatable
cannot be used as an existential type. The following code won’t compile:
func printError(_ error: Error & Equatable) {
print(error)
}
In the same way, when we want to define an Equatable
data type that embeds an Error
, the Equatable
conformance is broken due to the Error
type:
enum State: Equatable {
case loading
case loaded
case failed(Error) // Error does not conform to Equatable
}
We can try to enforce the conformance to Equatable
for the embedded error, but for the aforementioned reason, this code won’t compile either:
enum State: Equatable {
case loading
case loaded
case failed(Error & Equatable)
}
One way to make it work is to define our own Equatable
error type:
struct NetworkError: Error, Equatable {}
struct DatabaseError: Error, Equatable {}
enum StateError: Error, Equatable {
case network(NetworkError)
case database(DatabaseError)
}
enum State: Equatable {
case loading
case loaded
case failed(StateError)
}
But this comes with a price: we leak the implementation details. We should not need to know the type of error, or even less, the technology behind it. Using network
or database
is an information reserved to the dependency injection layer, not the business layer. We want the error to be just that … an Error
.
One other way to overcome this problem would be to use constraint generics:
enum State<ErrorType: Error & Equatable>: Equatable {
case loading
case loaded
case failed(ErrorType)
}
This does not seem to be a good/easy solution:
- we still leak implementation details:
let state = State<StateError>.loading
- we have to specify the error type even if the state is not
failed
:let state: State = .loaded
won’t compile because theErrorType
is missing.
Before trying to find another solution to this problem, I think it is important to understand why the usage of Equatable
can be tricky.
The Equatable protocol
According to the Swift Standard Library:
Types that conform to the
Equatable
protocol can be compared for equality using the equal-to operator (==
) or inequality using the not-equal-to operator (!=
).
A lot of Swift types conform to Equatable.
let a: Int = 2
let b: Int = 3
let c: Int = 2
a == b // false
a == c // true
let a: String = "2"
let b: String = "3"
let c: String = "2"
a == b // false
a == c // true
Although Int
and String
both conform to Equatable
, it does not mean they can be compared for equality.
let a: Int = 2
let b: String = "3"
a == b
// error: binary operator '==' cannot be applied to operands of type 'Int' and 'String'
// overloads for '==' exist with these partially matching parameter lists: (Int, Int), (String, String)
The error seems logical: we cannot compare Ints and Strings for equality in the real life either. But what really forbids us from doing this in Swift? Well, the answer lies in the definition of the protocol:
public protocol Equatable {
/// Returns a Boolean value indicating whether two values are equal.
/// - Parameters:
/// - lhs: A value to compare.
/// - rhs: Another value to compare.
static func == (lhs: Self, rhs: Self) -> Bool
}
The ==
function expects two parameters of the same type, that is to say Self
, the type conforming to Equatable
. Obviously, Int
and String
are not of the same type. This explains why we can’t compare them for equality.
Another carateristic of the Equatable
protocol is that it cannot be used as an existential type.
If you try to use the Equatable
protocol as an existential type:
let value: Equatable = "1"
You will be presented with this error:
protocol ‘Equatable’ can only be used as a generic constraint because it has Self or associated type requirements
In the protocol definition, Self
is used to strictly identify the type to be compared so the test for equality can be performed. By hiding the concrete type of the variable for the benefit of Equatable
, the compiler would use that signature for the equality check:
static func == (lhs: Equatable, rhs: Equatable) -> Bool {}
There is not enough information in Equatable
to perform such a comparison.
This is also what forbids us from using Equatable
in arrays:
let array: [Equatable] = [1, 2, 3, 4, 5]
The compiler doesn’t have enough information about each element. It would be impossible to perform a comparison for equality as a whole.
EquatableError
Let’s get back to our problem:
- We want to use
Error
without leaking implementation details - We want to embed
Error
in someEquatable
data type without breaking theEquatable
conformance
Ok, the base implementation could be:
struct EquatableError: Error, Equatable {
let base: Error
}
Obviously this won’t compile since the base
is not Equatable
… it’s a snake biting its own tail!
Let’s satisfy the compiler and add an ==
function:
struct EquatableError: Error, Equatable {
let base: Error
static func ==(lhs: EquatableError, rhs: EquatableError) -> Bool {
return type(of: lhs.base) == type(of: rhs.base) &&
lhs.base.localizedDescription == rhs.base.localizedDescription
}
}
We make sure both base
errors are the same type and their only property is equal, but it can be easily fooled:
struct NetworkError: LocalizedError, Equatable {
let code: Int
var errorDescription: String? { "Foo" }
}
let errorA = EquatableError(base: NetworkError(code: 1))
let errorB = EquatableError(base: NetworkError(code: 2))
errorA == errorB // will output `true`
We would expect errorA
and errorB
to NOT BE equal because of their different code
properties, but unfortunately only the localizedDescription
is taken into account in the equality check.
We should depend on the ==
function of the base
error:
struct EquatableError: Error, Equatable {
let base: Error
private let equals: (Error) -> Bool
init<Base: Error & Equatable>(_ base: Base) {
self.base = base
self.equals = { ($0 as? Base) == base }
}
static func ==(lhs: EquatableError, rhs: EquatableError) -> Bool {
lhs.equals(rhs.base)
}
}
Let’s give it a spin:
enum Reason: Equatable {
case badNetwcork
case noNetwork
}
struct NetworkError: Error, Equatable {
let code: Int
let reason: Reason
let isRecoverable: Bool
static func ==(lhs: NetworkError, rhs: NetworkError) -> Bool {
lhs.code == rhs.code && lhs.reason == rhs.reason
}
}
let networkErrorA = NetworkError(code: 1701, reason: .badNetwcork, isRecoverable: false)
let networkErrorB = NetworkError(code: 1701, reason: .badNetwcork, isRecoverable: true)
let networkErrorC = NetworkError(code: 1702, reason: .noNetwork, isRecoverable: false)
networkErrorA == networkErrorB // prints true 👍
networkErrorA == networkErrorC // prints false 👍
let equatableErrorA = EquatableError(networkErrorA)
let equatableErrorB = EquatableError(networkErrorB)
let equatableErrorC = EquatableError(networkErrorC)
equatableErrorA == equatableErrorB // prints true 👍
equatableErrorA == equatableErrorC // prints false 👍
The only downside of this implementation is the Equatable
constraint on the Base
type. What if we want to make any Error
be an EquatableError
?
Well, there’s an acceptable solution to that:
struct EquatableError: Error, Equatable {
let base: Error
private let equals: (Error) -> Bool
init<Base: Error>(_ base: Base) {
self.base = base
self.equals = { String(reflecting: $0) == String(reflecting: base) }
}
init<Base: Error & Equatable>(_ base: Base) {
self.base = base
self.equals = { ($0 as? Base) == base }
}
static func == (lhs: EquatableError, rhs: EquatableError) -> Bool {
lhs.equals(rhs.base)
}
}
If the base
error is not Equatable
we can use reflection to inspect its internal structure and use it as a comparison point. Swift provides us with a handy String
initializer for this:
let networkError = NetworkError(code: 1701, reason: .badNetwcork, isRecoverable: false)
let description = String(reflecting: networkError)
print(description) // will print NetworkError(code: 1701, reason: Reason.badNetwcork, isRecoverable: false)
Using String(reflecting:)
is an acceptable fallback, but not 100% reliable since we can still make the type conform to CustomDebugStringConvertible
or CustomStringConvertible
or TextOutputStreamable
and influence the resulting String.
In the end our model can embed the EquatableError
while being Equatable
and without leaking implementation details.
enum State: Equatable {
case loading
case loaded
case failed(EquatableError)
}
Then we can still get the underlying concrete Error
:
if case let State.failed(error) = state,
let networkError = error.base as? NetworkError {
....
}
Bonus
We can add some nice utilities to our code.
First, let’s make EquatableError
conform to CustomStringConvertible
and delegate the description to its base error.
struct EquatableError: Error, Equatable, CustomStringConvertible {
...
var description: String {
"\(self.base)"
}
}
In doing so, we can print the EquatableError
as if it was its base.
let networkError = NetworkError(code: 1701, reason: .badNetwcork, isRecoverable: false)
let equatableError = EquatableError(networkError)
print(equatableError) // prints NetworkError(code: 1701, reason: Reason.badNetwcork, isRecoverable: false)
In the same way, we can also delegate the localizedDescription
to the base error:
struct EquatableError: Error, Equatable, CustomStringConvertible {
...
var localizedDescription: String {
self.base.localizedDescription
}
}
Let’s also ease the making of an EquatableError
from any error:
extension Error where Self: Equatable {
func toEquatableError() -> EquatableError {
EquatableError(self)
}
}
extension Error {
func toEquatableError() -> EquatableError {
EquatableError(self)
}
}
let equatableError = NetworkError(
code: 1701,
reason: .badNetwcork,
isRecoverable: false
).toEquatableError()
And finally, we can provide a helper function to unwrap the base error as a concrete Error
:
struct EquatableError: Error, Equatable, CustomStringConvertible {
...
func asError<Base: Error>(type: Base.Type) -> Base? {
self.base as? Base
}
}
if let networkError = equatableError.asError(type: NetworkError.self) {
...
}
Here is the complete implementation for EquatableError
:
struct EquatableError: Error, Equatable, CustomStringConvertible {
let base: Error
private let equals: (Error) -> Bool
init<Base: Error>(_ base: Base) {
self.base = base
self.equals = { String(reflecting: $0) == String(reflecting: base) }
}
init<Base: Error & Equatable>(_ base: Base) {
self.base = base
self.equals = { ($0 as? Base) == base }
}
static func ==(lhs: EquatableError, rhs: EquatableError) -> Bool {
lhs.equals(rhs.base)
}
var description: String {
"\(self.base)"
}
func asError<Base: Error>(type: Base.Type) -> Base? {
self.base as? Base
}
var localizedDescription: String {
self.base.localizedDescription
}
}
extension Error where Self: Equatable {
func toEquatableError() -> EquatableError {
EquatableError(self)
}
}
extension Error {
func toEquatableError() -> EquatableError {
EquatableError(self)
}
}
I hope this helps. Thanks for reading.
Special thanks to Ryan F. and Ryan G. for their reviews.