Profile
Back to NewsBack
GitHub Trending 8 min
Reader Mode
ordo-one/equatable: A Swift package that provides macros for generating Equatable conformances for structs for high performance SwiftUI view diffing.

ordo-one/equatable: A Swift package that provides macros for generating Equatable conformances for structs for high performance SwiftUI view diffing.

4 hours ago

</a> </a>

codecov</a>

Equatable Macros

A Swift package that provides macros for generating Equatable conformances for structs.

Why Use Equatable Macros?

SwiftUI’s performance hinges on efficient view diffing—but the default behavior often falls short, especially with closures and complex view models. Manually writing Equatable conformances is tedious and error-prone.

Equatable Macros solve this by generating optimized Equatable implementations for your SwiftUI views, ensuring accurate and performant diffing out of the box. They eliminate boilerplate, prevent regressions, and help you avoid unnecessary re-renders—boosting UI performance while keeping your codebase clean and maintainable.

Ideal for teams scaling SwiftUI in large apps.

Overview

The @Equatable macro generates an Equatable implementation that compares all of a struct's stored instance properties. Properties with SwiftUI property wrappers like @State and @Environment are excluded because they trigger view updates through other mechanisms. Properties that aren't Equatable and don't affect the output of the view body can be marked with @EquatableIgnored to exclude them from the generated implementation. Closures are not permitted by default but can be marked with @EquatableIgnoredUnsafeClosure to indicate that they are safe to exclude from equality checks.

Installation

Add this package to your project using Swift Package Manager:

dependencies: [
    .package(url: "https://github.com/ordo-one/equatable.git", from: "1.0.0")
]

Then add the dependency to your target:

.target(
    name: "YourTarget",
    dependencies: [
        .product(name: "Equatable", package: "equatable")
    ]
)

Usage

Apply the @Equatable macro to structs to automatically generate Equatable conformance:

import Equatable
import SwiftUI

@Equatable struct ProfileView: View { var username: String // Will be compared @State private var isLoading = false // Automatically skipped @ObservedObject var viewModel: ProfileViewModel // Automatically skipped @EquatableIgnored var cachedValue: String? // This property will be excluded @EquatableIgnoredUnsafeClosure var onTap: () -> Void // This closure is safe and will be ignored in comparison (in order for it to be safe we must be sure that this closure does not capture value types on call site) let id: UUID // Will be compared first for short-circuiting equality checks var body: some View { VStack { Text(username) if isLoading { ProgressView() } } } }

The generated extension will implement the == operator with property comparisons ordered for optimal performance (e.g., IDs and simple types first):

extension ProfileView: Equatable {
    nonisolated public static func == (lhs: ProfileView, rhs: ProfileView) -> Bool {
        lhs.id == rhs.id && lhs.username == rhs.username
    }
}

Property-level comparison strategies

Use @EquatableCompared to select how an instance stored property participates in equality. It works with stored properties in @Equatable structs, including properties with willSet or didSet observers. Other properties retain their normal comparison behavior and ordering.

Compare a projected value

A key path lets a property participate even when its type does not conform to Equatable:

final class Document {
    let identifier: Int
    let title: String

init(identifier: Int, title: String) { self.identifier = identifier self.title = title } }

@Equatable struct DocumentState: Hashable { @EquatableCompared(by: \Document.identifier) var document: Document }

Two DocumentState values compare equal when their document identifiers match, even if they contain different document instances or titles. Hashing also uses the identifier.

Provide a key-path literal with its root type, such as \Document.identifier; the marker cannot infer the root of \.identifier from the property it annotates. Nested paths and optional chaining are supported, such as \Container.document?.identifier. An optional property needs a key path rooted in that optional type, or a strategy that explicitly accepts an optional value.

Compare reference identity

final class Coordinator {}

@Equatable struct ScreenState: Hashable { @EquatableCompared(.identity) var coordinator: Coordinator? }

Identity uses ===: two references to the same object compare equal, and distinct objects compare unequal even if their contents match. Optional references are supported, with two nil values comparing equal. Hashing uses the object's ObjectIdentifier, or the nil representation. Identity comparison requires a class reference; value types are rejected by the compiler.

Reuse a normalization strategy

enum LowercasedString: EquatableComparisonStrategy {
    static func comparisonValue(for value: String) -> String {
        value.lowercased()
    }
}

@Equatable struct Category: Hashable { @EquatableCompared(using: LowercasedString.self) var name: String }

Category(name: "BOOKS") equals Category(name: "books"), and both use the same normalized value for hashing. Strategy types can be generic or namespaced. Pass an explicit metatype, such as LowercasedString.self, rather than a variable holding a metatype.

A strategy implements EquatableComparisonStrategy, whose Value is the property input and whose ComparisonValue: Equatable is the comparison representation. Swift can infer both associated types from comparisonValue(for:). A strategy can accept optional inputs and normalize nil explicitly.

Requirements and semantics

  • Projected values only need Equatable for equality. If the containing type uses generated
hashing, the projected value must also conform to Hashable; the compiler enforces this.
  • Projections must be deterministic and free of side effects. They run when a comparison or
hash is requested and obey the selected isolation mode. Equality still short-circuits.
  • Projections read current values; they do not snapshot mutable object state. Identity does
not detect mutations within an existing object. Comparison keys must remain stable while a value is stored in a Set or used as a dictionary key.
  • Each property can have one comparison marker. Combining it with @EquatableIgnored,
@EquatableIgnoredUnsafeClosure, or an automatically skipped SwiftUI wrapper produces an error.
  • Comparison markers require an enclosing @Equatable struct.
Computed properties, static/class properties, closures, and multi-property declarations are unsupported. Write a separate declaration for each annotated property.

Isolation

Equatable macro supports generating the conformance with different isolation levels by using the isolation parameter. The parameter accepts three values: .nonisolated (default), .isolated, and .main (requires Swift 6.2 or later). The chosen isolation level will be applied to the generated conformances for both Equatable and Hashable (if applicable).

Nonisolated (default)

The generated Equatable conformance is nonisolated, meaning it can be called from any context without isolation guarantees.
@Equatable(isolation: .nonisolated) (also omitting the parameter uses this mode)
struct Person {
    let name: String
    let age: Int
}

expands to:

extension Person: Equatable {
  nonisolated public static func == (lhs: Person, rhs: Person) -> Bool {
    lhs.name == rhs.name && lhs.age == rhs.age
  }
}

Isolated

The generated Equatable conformance is isolated, meaning it can only be called from within the actor's context.
@Equatable(isolation: .isolated)
struct Person {
    let name: String
    let age: Int
}

expands to:

extension Person: Equatable {
  public static func == (lhs: Person, rhs: Person) -> Bool {
    lhs.name == rhs.name && lhs.age == rhs.age
  }
}

Main (requires Swift 6.2 or later)

A common case is to have a @MainActor isolated type, SwiftUI views being a common example. Previously, the generated Equatable conformance had to be nonisolated in order to satisfy the protocol requirement. This would then restrict us to access only nonisolated properties of the type in the generated Equatable function — which meant that we had to ignore all @MainActor isolated properties in the equality comparison. Swift 6.2 introduced
isolated conformances allowing us to generate Equatable conformances which are bound to the @MainActor. In this way the generated Equatable conformance can access @MainActor isolated properties of the type synchronously and the compiler will guarantee that the conformance will be called only from the @MainActor context.

We can do so by specifying @Equatable(isolation: .main), e.g:

@Equatable(isolation: .main)
@MainActor
struct Person {
    let name: String
    let age: Int
}

expands to:

extension Person: @MainActor Equatable {
    public static func == (lhs: Person, rhs: Person) -> Bool {
        lhs.name == rhs.name && lhs.age == rhs.age
    }
}

Safety Considerations

Closures marked with @EquatableIgnoredUnsafeClosure should not affect the logical identity of the type. For example:

Example - Safe Usage of @EquatableIgnoredUnsafeClosure

struct UserActions: Equatable {
    let id: UUID
    let name: String
    @EquatableIgnoredUnsafeClosure
    var onTap: () -> Void
}
struct ContentView: View {
    var body: some View {
        UserActions(id: UUID(), name: "Example") {
            print("User tapped") // This closure does not capture value types on call site
                                // and does not influence rendering of UserActions view's body.
        }
    }
}
In this example, onTap will be excluded from equality comparisons, allowing the UserActions instances to be properly compared based only on id and name.

Example - Unsafe Usage of @EquatableIgnoredUnsafeClosure

struct DemoView: View {
    @State var enabled = false
    var body: some View {
        Text("Enabled? \(enabled)")
        .onTapGesture(perform: {
            enabled.toggle()
        })
        Content(enabled: enabled)
    }
}
struct Content: View {
    var enabled: Bool
    var body: some View {
        ViewTakesClosure(
        label: "This view takes a closure",
        onTapGesture: {
            // This will always print "enabled? False", because this ViewTakesClosure
            // is never re-rendered (its Equatable inputs never change).
            // The closure captures the initial value of enabled=false.
            print("enabled? \(enabled)")
        })
    }
}
@Equatable
struct ViewTakesClosure: View {
    let label: String
    @EquatableIgnoredUnsafeClosure let onTapGesture: () -> Void
    var body: some View {
        Text(label)
        .onTapGesture(perform: onTapGesture)
    }
}
In this example ViewTakesClosure's closure captures the enabled value on callsite and since it's marked with @EquatableIgnoredUnsafeClosure it will not cause a re-render when the value of enabled changes. The closure will always print the initial value of enabled which is an incorrect behavior.

Hashable conformance

If the type is marked as conforming to Hashable the compiler synthesized Hashable implementation will not be correct. That's why the @Equatable macro will also generate a Hashable implementation for the type that is aligned with the Equatable implementation.

import Equatable
@Equatable
struct User: Hashable {

let id: Int @EquatableIgnored var name = "" }

Expanded:

extension User: Equatable {
    nonisolated public static func == (lhs: User, rhs: User) -> Bool {
        lhs.id == rhs.id
    }
}
extension User {
    nonisolated public func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
}

References

This package is inspired by Cal Stephen's & Miguel Jimenez's blog post Understanding and Improving SwiftUI Performance.
Chat with me