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
Equatablefor equality. If the containing type uses generated
Hashable; the compiler enforces this.
- Projections must be deterministic and free of side effects. They run when a comparison or
- Projections read current values; they do not snapshot mutable object state. Identity does
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
@Equatablestruct.
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 generatedEquatable 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 generatedEquatable 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)
}
}