Making an Enum Equatable when it has an associated type of Error

We are developing a distributable package (framework) and have an enum that looks something like this:

public enum Status: Equatable {
    case waiting
    case initialized
    case failed(Error)
}

We definitely want the enum to be Equatable, we've found that usage of it is much too tedious if not.

Of course conformance to Equatable doesn't just work. So we are looking for ways to make this enum Equatable and still support the associated Error type for the failed case.

One idea that we came up with was to create a protocol "UnconstrainedEquatable":

/// A type that can compare itself to other instances of UnconstrainedEquatable.
/// The conforming types must check to make sure the passed in instance is of the same
/// type as Self.
public protocol UnconstrainedEquatable {
    /// Returns a value that specifies if this instance is equal to another instance.
    /// - Parameter other: The other instance to compare for equality.
    func isEqual(to other: UnconstrainedEquatable) -> Bool
}

public extension UnconstrainedEquatable where Self: Equatable {
    /// Provides a default implementation of isEqual that uses Equatable conformance
    /// for the heavy lifting.
    func isEqual(to other: UnconstrainedEquatable) -> Bool {
        guard let other = other as? Self else { return false }
        return self == other
    }
}

/// Make sure the error types that we may through conform:
extension SomeError: UnconstrainedEquatable, Equatable { }
extension SomeOtherError: UnconstrainedEquatable, Equatable { }

This is a generic approach (UnconstrainedEquatable has nothing to do with Error the way it's defined) and it's usage would then mean our status would look like this, (and we would implement the == manually somewhere below):

public enum Status: Equatable {
    case waiting
    case initialized
    case failed(Error & UnconstrainedEquatable)
}

However, that doesn't quite sit right for some reason. Are we somehow shirking the strongly typed system of swift?

Another idea is to do something similar but more targeted on the Error type:

public protocol BasicError: Error {
    /// Returns a value that specifies if this instance is equal to another instance.
    /// - Parameter other: The other instance to compare for equality.
    func isEqual(to other: BasicError) -> Bool
}

public extension BasicError where Self: Equatable {
    /// Provides a default implementation of isEqual that uses Equatable conformance
    /// for the heavy lifting.
    func isEqual(to other: BasicError) -> Bool {
        guard let other = other as? Self else { return false }
        return self == other
    }
}

extension SomeError: BasicError, Equatable { }
extension SomeOtherError: BasicError, Equatable { }

Where the name BasicError can/should be replaced with "Error".

It's usage would then mean our status would look like this:

public enum Status: Equatable {
    case waiting
    case initialized
    case failed(BasicError)
}

But that doesn't sit quite right either. This "BasicError" or "OurProductError" protocol doesn't add anything product specific to the Error protocol, it adds something to help us deal with Equatable in an un-constrained way.

I guess we could name the protocol UnconstrainedEquatableError?

Does anybody have any thoughts on the ideas above? Are there other established ways to solve this?

Any comments on this?

I think you have correctly identified Swift limitations relating to your problem as stated. However it may be worth exploring some "other" solutions. This will require making some assumptions about what you're trying to do though.

We are developing a distributable package (framework)

If this Status type is a type to represent a situation in your framework, and you are the one constructing it, you could put your framework type in the .failed(FrameworkError) case. That type can conform to Error so applications can throw it, and if it needs to wrap an underlying system error it can do so, and deal with comparing them in some way appropriate for your framework.

An underlying issue here is that there's not really a reasonable way to compare errors that is universally appropriate for every context. In Cocoa for example, we might do this by domain and code, but even then the same code can have different causes, which is maybe important, or maybe not. In Swift we're more likely to pack random information about what was happening into the error, which maybe makes error distinct or maybe not.

As a result, one way to look at your problem is that it requires "taking a position" on what it means to equate your type, or the underlying Error, because there is no "natural" meaning.

We definitely want the enum to be Equatable, we've found that usage of it is much too tedious if not.

One position is to say "this type ought to conform to Equatable to get some convenient behavior/usage, but we don't especially what kind of implementation it is to compare the errors". Behind this door you could implement something like lhs.failed(_) == rhs.failed(_) to consider all failed statuses equal (or unequal, if that's more expected) regardless of payload.

Maybe a middle-ground would be to extend your UnconstrainedEquatable with an arbitrary implementation for non-Equatables, and use your other conformance for Equatable

extension UnconstrainedEquatable {
    func isEqual<O>(to other: O) -> Bool {
        if let _ = self as? O {
            return true
        }
        return false
    }
}

Again a key issue here is taking a position on what it "means" for your Status type to be equal.

Another thing worth bringing into this discussion is the PAT problem, which you would encounter if you tried to use Swift.Equatable in your field

protocol EquatableError: Error,Equatable {}
enum Status {
    case failed(EquatableError) //Protocol 'EquatableError' can only be used as a generic constraint because it has Self or associated type requirements
}

This is a well-known problem in Swift's typesystem, related to lack of existentials for PATs, which Equatable is one example. There are a bunch of workarounds for this problem, and we regularly discuss if the pattern should be supported.

But your BasicError and UnconstrainedEquatable are more-or-less a version of Equatable without the PAT, which is one of the workarounds to the PAT issue. So in terms of exploring other solutions to your problem, the space looks a lot like other workarounds to the PAT problem.

Other workarounds would include, generics

enum Status<E: Error, Equatable> {
    case failed(E)
}

Declaring your own PAT

enum ExampleError: Error,Equatable {}

protocol Status {
    associatedtype Error: Equatable, Swift.Error
    static func failed(_ arg: Error) -> Self
}
enum StatusImplementation: Status {
    typealias Error = ExampleError
    case failed(Error)
}

Thank you @Drew_Crawford1! That gives me some good items to think about and discuss with the team. I appreciate your time.

A passing note:
I assume you typo-ed and meant:
enum Status<E: Error & Equatable> {

1 Like