Swift Equatable Pitfalls [Part 1]

9 min read Original article ↗

How Incomplete Conformance Causes Sneaky Bugs

It’s incredibly easy to write static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id } and move on. From a domain perspective, comparing IDs makes perfect sense. Two events with the same ID represent the same WWDC event.

But Swift's standard library doesn't care about business rules. It uses == everywhere from arrays, sets, and sorting algorithms. In this series, we'll look at how incorrectly conforming to Equatable creates unexpected behaviors that easily fly under the radar.

It's also helpful to first understand what Equatable is and then go through the issues I found.

What is Equatable?

According to Equatable documentation, in simple terms, it's a set of rules that let the compiler compare two objects of a datatype using the == and != operators. Most default datatypes in Swift already conform to Equatable . If we want to use the == and != operators on our custom datatypes, we must make sure those types conform to Equatable.

Mathematically, there is a set of rules that back the Equatable protocol, which is also mentioned in the documentation.

  • a == a is always true (Reflexivity)
  • a == b implies b == a (Symmetry)
  • a == b and b == c imply a == c (Transitivity)

These basic properties are necessary to determine that a number or entity is truly equal at least mathematically.

These principles sounds straight forward right? Does your app's idea of 'equal' (say, same event ID) align with these rules? In practice, it often doesn't, leading to subtle breaks.

Basic Conformance in Swift

In Swift, when we manually implement Equatable conformance, we need to make sure the datatype is theoretically equatable, though you could argue this also depends on the datatype context.

We can conform to Equatable as follows for an Event object:

struct Event: Equatable { 
  let id: Int
  let location: String
  let isInvited: Bool
}

As you know, if every field in a custom datatype is Equatable , the whole type implicitly satisfies the for Equatable requirement. If not, we must implement it as follows:

extension Event { 
  static func == (lhs: Self, rhs: Self) -> Bool { 
    lhs.id == rhs.id && lhs.location == rhs.location && lhs.isInvited == rhs.isInvited
  }
}

This implementation is optional for Event because the compiler synthesizes it when every field is Equatable.

While this isn't the only way to satisfy the conformance, we could also write:

static func == (lhs: Self, rhs: Self) -> Bool { 
  lhs.id == rhs.id
}

or compare just the location, or a combination of id and location .

These are all technically valid ways to make Event conform to Equatable.

I've fallen into this trap myself, focusing on domain logic like comparing with id feels intuitive, but Swift's standard library doesn't care about business rules. It uses == everywhere: in arrays, sets, even sorting. We need to think broader.

At first glance, it may seem that omitting some fields from conformance won’t cause bugs. However, developers sometimes confuse a datatype’s Equatable conformance with its identity. This leads to the belief that including only the id in the conformance is enough to determine whether two objects are the same. The real issue lies in the definition of “same.” Does it mean two entities represent the same actor with respect to the domain? Say an Event with id 1 represents WWDC26, so no other event will share that id. If so, two Event objects can be compared and identified as referring to the same WWDC event. That is correct from a domain perspective, when it satisfies the reflexivity property.

But Equatable isn’t a domain specific protocol that lets us distinguish between events. Equatable is used throughout many Swift APIs, from the == operator to Array’s contains function, and more.

So, how does this bite us in a real project? Let's zoom into an SDK-based app

The Setup - Reflexivity

If you have worked with shared SDKs, mostly at enterprise apps, you know there can be multiple occasion, where fields, functions and modules are ignored because they don't apply to a specific product.

The app uses an SDK that handles API requests and responses. It follows a slightly modified clean architecture. A Usecase calls the end points via the SDK to fetch data from the server, and we inject a Converter into the Usecase to convert the SDK model to the domain/view model used within the application. When new modifications are introduced, they will most likely affect specific areas because the code base is modular. For example, if we introduce a new field that already exists in the SDK, we can assume the directly impacted areas will be the class/struct definition and the converter. The rest of the model won't be affected unless you work with the new field or functionality.

Assume the SDK has an SDKEventParticipant model, which is created by converting the API response JSON.

class SDKEventParticipant { 
    let id: Int64
    var email: String?
    var employee: SDKActorLookup?
    var customer: SDKActorLookup?
    var guest: SDKActorLookup?
    var type: SDKEventParticipantType
    var isInvited: Bool
    var status: String
    
    init(id: Int64, email: String?, employee: SDKActorLookup?, customer: SDKActorLookup?, guest: SDKActorLookup?, type: SDKEventParticipantType, isInvited: Bool, status: String) {
        self.id = id
        self.email = email
        self.employee = employee
        self.customer = customer
        self.guest = guest
        self.type = type
        self.isInvited = isInvited
        self.status = status
    }
    
    func getLookupRecord() -> SDKActorLookup? { 
        switch type { 
        case .customer:
            return customer
        case .employee:
            return employee
        case .guest:
            return guest
        default:
            return nil
        }
    }
}

We can examine SDKEventParticipantType and SDKActorLookup :

enum SDKEventParticipantType: String { 
    case email
    case employee
    case customer
    case guest
}

class SDKActorLookup {
    let id: Int64
    var email: String

    init(id: Int64, email: String) {
        self.id = id
        self.email = email
    }
}

Since this is an example, I'm using a simple model that isn't actually used in the app, but makes explaining and reproducing much easier. The EventParticipant is the datatype (representing the SDKEventParticipant ) that will be used throughout the app.

class EventParticipant: Identifiable { 
    let id: Int64
    var email: String?
    let type: SDKEventParticipantType
    var isInvited: Bool = false
    var status: String?
    let event: SDKEventParticipant

    init(id: Int64, type: SDKEventParticipantType, event: SDKEventParticipant) {
        self.id = id
        self.type = type
        self.event = event
    }
}

The converter:

struct EventParticipantConverter { 
    static func getEventParticipant(from sdkEvent: SDKEventParticipant) -> EventParticipant { 
        let event = EventParticipant(
            id: sdkEvent.id,
            type: sdkEvent.type,
            event: sdkEvent
        )
        event.isInvited = sdkEvent.isInvited
        event.status = sdkEvent.status
        switch sdkEvent.type {
        case .email:
            event.email = sdkEvent.email
        case .employee:
            event.email = sdkEvent.getLookupRecord()?.email 
        case .customer:
            event.email = sdkEvent.getLookupRecord()?.email
        default:
            break
        }
        return event
    }
}

The main point to note is that the SDK is used by multiple projects and products, so you might see specific API fields ignored, unused, or simply not yet introduced in a given product.

See how the converter skips .guest? That's the assumption: "Our app doesn't need it yet." Harmless... until it does

Hence, even though it exists in the SDK side model, the app’s model assumes it won’t be used and won’t be needed; at least until it’s introduced into the product.

Based on this assumption, the rest of the application is also built by ignoring the .guest field, as seen in the converter and in the Equatable conformance.

extension EventParticipant: Equatable { 
    static func == (lhs: EventParticipant, rhs: EventParticipant) -> Bool { 
        guard lhs.type == rhs.type else { return false }
        
        switch lhs.type { 
        case .email:
            return lhs.email == rhs.email
        case .customer:
            return lhs.event.getLookupRecord()?.id == rhs.event.getLookupRecord()?.id
        case .employee:
            return lhs.event.getLookupRecord()?.id == rhs.event.getLookupRecord()?.id
        default:
            return false
        }
    }
}

The implementation of this conformance is particularly interesting because, from my understanding the developer intended to compare two EventParticipant objects to determine whether they are the same based on the participant's type and corresponding values, such as email or customer/employee id , depending on the participant type.

This information alone seems sufficient to make everything else work properly from the view's standpoint.

For example, if we want to remove a participant from an event, we could simply do the following:

func removeParticipant(_ participant: EventParticipant) { 
  if let index = participants.firstIndex(of: participant) { 
    participants.remove(at: index)
    ...
  } else { 
    ...
  }
}

During selection handling:

func didSelect(_ participant: EventParticipant) { 
  if let index = selecetedParticipants.firstIndex(of: participant) { 
    selectedParticipants.remove(at: index)
  else { 
    selecetedParticipants.append(participant)
  }
}

In these functions, the firstIndex(of: _) method uses the == operator internally to find the index. These operations work perfectly and don't cause any issues.

The Bug

The problem occurs when the server returns the .guest type to the app. When we implement the guest as a participant option, the adaptation seem almost as simple as updating the converter, and everything else should be handled smoothly.

struct EventParticipantConverter { 
    static func getEventParticipant(from sdkEvent: SDKEventParticipant) -> EventParticipant { 
        ...
        switch sdkEvent.type {
        ...
        case .guest:
            event.email = sdkEvent.getLookupRecord()?.email
        }
        return event
    }
}

It's common to overlook the EventParticipant extension since we were only interested in conforming to Equatable because the compiler didn't allow us to compare two objects. Once that was sorted, we were likely to ignore its implementation thereafter.

EventParticipant itself will look like it can adapt to the .guest addition; the view will show the participants and seems to work at a higher level.

Comparing both the Entities with guest participant type
Comparing both the Entities with `.guest` participant type

The unfortunate part is that, since there was technically nothing wrong with the code introducing the .guest participant, the project compiles and even runs as usual.

While it isn't wrong to include every field for Equatable conformance, failing to do so will inevitably invite bugs over time.

In this example, the mistake is fairly obvious and not too hard to find once you test the affected area. It might eventually lead to an unhandled case in Equatable conformance. This isn't always obvious; sometimes the mistakes won't be found until after release, when user are affected.

Adding .guest to the switch handling of the Equatable conformance of EventParticipants
Adding `.guest` to the switch handling of the `Equatable` conformance of `EventParticipants`

Fixing a missing .guest case is a relatively easy catch once you notice the UI missing a beat. But the bugs get much weirder from here.

In the next part, we'll look at Symmetry (a == b implies b == a). When you violate this principle, the simple act of flipping the order of your comparison changes the result, leading to incredibly subtle bugs in your codebase that are much harder to track down.