Providing a default Hashable instance when using an enum?

I'm working on a custom identifier protocol where I can provide an identifier from within the type. This works fine for structs but I ran into a wall when using it with an enum. Below is a quick example of what I'm hoping to do. This does not work since cases .state and .town pass self to hasher.combine(self). The compiler is happy since ExampleType conforms to Hashable, but results in infinite loop and crashes, since you can't actually use self.

Is there a way to avoid this infinite loop while using an enum? Is there a way to bypass my implementation and fallback to use the Hashable "default implementation" for certain enum cases?

public protocol CustomID: Hashable {
  associatedtype ID: Hashable
  var identifier: ID { get }
}

public enum ExampleType: Hashable, CustomID {
  case city(name: String, address: Address)
  case town
  case state
  
  public var identifier: AnyHashable {
    switch self {
    case let .city(name, _):
      return name
    case .state:
      return self
    case .town:
      return self
    }
  }
}

extension ExampleType {
  public func hash(into hasher: inout Hasher) {
    hasher.combine(identifier)
  }
  
  public static func == (lhs: Self, rhs: Self) -> Bool {
    return lhs.identifier == rhs.identifier
  }
}

Are you sure you need a custom implementation here? This works out of the box:

struct Address: Hashable {
    let someFieldHere: String
}

enum ExampleType: Identifiable, Hashable {
    case city(name: String, address: Address)
    case town
    case state

    var id: Self { self }
}

That does work out of the box yes, but it doesn't allow me to pick and choose which associatedValues I want to be part of the types identity.

e.g.

  public var identifier: AnyHashable {
    switch self {
    case let .city(name, _):
      return name
    case .state:
      return self
    case .town:
      return self
    }
  }

In my example, for the city case, I only want name to considered the when performing the Hashing/Equatable functions, and not include Address.

Ignoring part of properties in Hashable implementation IMO is an anti-pattern. I strongly recimmend against it. Equality of a == b means that it is safe to replace any usage of a with b and vice versa. But in you example it is not true for usages of ExampleType that access address.

If you are not using address - delete it altogether.

If you want to keep a collection of items unique by identifier - use Dictionary instead of Set.

If you want to express another relationship - give it an appropriate name, don’t call it Equatable/Hashable.

So removing extension for ExampleType would solve the infinite recursion.

In more general case, using name as identifier could be not unique enough - if there are other cases which also have string identifier. Or if you are mixing different types conforming to CustomID.

This can be solved using a parallel enum as an identifier:

public enum ExampleType: Hashable, CustomID {
  case city(name: String, address: Address)
  case town
  case state

  public enum ID: Hashable {
    case city(name: String)
    case town
    case state
  }
  
  public var identifier: ID {
    switch self {
    case let .city(name, _):
      return .city(name)
    case .state:
      return .state
    case .town:
      return .town
    }
  }
}
2 Likes

Then consider this implementation:

    static func == (a: Self, b: Self) -> Bool {
        a.id == b.id
    }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }

    var id: AnyHashable {
        switch self {
        case .city(let name, _):
            return "city: " + name
        case .town:
            return "town"
        case .state:
            return "state"
        }
    }

Note that identical values might be necessarily equal and equal values might be necessarily identical, but strictly speaking these are two different features (e.g. two reference objects might be equal but not identical, and two identical objects might have EQ implementation that always returns false). In some way "equivalence" is more connected to "hashability" (and vice versa) than to "identity".

Thanks for the suggestion. I went with a variation of this and dropped overriding hashable.