Kotlin 'equals ignoring fields' using data classes

#kotlin Nov 28, 2023 4 min Mike Kowalski

Equals ignoring fields is a pretty common operation. While often used within the test code to exclude selected fields from equality assertions, it can also be handy in our domain code.

With Kotlin data classes, we can implement equals ignoring fields without using reflection or extra libraries. This solution feels almost too simple to describe, but it’s interesting to compare it with the available alternatives.

Status quo

Let’s take a simple blog post representation as an example:

data class User(val id: UUID, val name: String)

data class BlogPost(
    val id: UUID,
    val createdAt: LocalDateTime,
    val updatedAt: LocalDateTime,
    val updatedBy: User,
    val publishDate: LocalDateTime,
    val author: User,
    val slug: String,
    val tags: List<String>,
    val content: String,
)

/**
 * Decides if the static HTML page representing specific blog post
 * should be rendered again. If there were no client-facing changes,
 * we want to avoid unnecessary work.
 */
fun BlogPost.requiresRendering(previous: BlogPost): Boolean {
    // true if any of the non-ignored fields has changed
    // we need the "equals ignoring fields" semantics here
    // to ignore 'updatedAt' and 'updatedBy' fields
    TODO("implement using equals ignoring fields")
}

val post1 = BlogPost(
    // ...
)

val post2 = post1.copy(
    updatedAt = LocalDateTime.now(), 
    updatedBy = anotherUser
)

The goal of the requiresRendering extension function is to determine if our blog engine should render the corresponding page again. If there were no “visible changes”, there’s no need to render anything! That’s why we want to compare the current version with the previous one, while ignoring “invisible” audit metadata (updatedAt and updatedBy fields). That’s exactly a task for the equals ignoring fields.

Most of the generic equals ignoring fields solutions I know rely on the reflection. As they often require passing ignored field names as parameters, we lose not only type safety, but also ease of refactoring. Let’s take a look at two examples using AssertJ and Apache Commons Lang:

// AssertJ - note: you probably won't use this outside of your test code :(
Assertions.assertThat(post2)
    .usingRecursiveComparison()
    .ignoringFields("updatedAt", "updatedBy")
    .isEqualTo(post1)

// Apache Commons Lang - mimicking Java Comparator API
val comparisonResult = CompareToBuilder.reflectionCompare(
    post2, 
    post1,
    // it's not clear that the list contains fields to ignore!
    listOf("updatedAt", "updatedBy")
)
Assertions.assertThat(comparisonResult).isEqualTo(0)

Surprisingly, both snippets won’t throw any exception if we pass a non-existent or misspelled field name! This makes certain mistakes pretty tricky to catch.

Data classes native solution

As long as we’re using Kotlin data classes, we can solve this problem with auto-generated equality checks. Yet, instead of comparing two objects directly, we need a customized copy of one of them. The code should explain this better than words:

// goal: check if 'post1' is equal to 'post2' (field by field)
// but ignore values of the 'updatedAt' and 'updatedBy' fields
val equalsIgnoringFields = post1 == post2.copy(
    // create a copy of 'post2' with ignored fields set to 
    // their corresponding values in 'post1'
    updatedAt = post1.updatedAt,
    updatedBy = post1.updatedBy
)

Here, we ignore certain fields by replacing their values with the content coming from the other object. This approach assumes that all the fields are covered by the equals() method. Fortunately, most data classes should rely on the auto-generated implementation.

Presented solution doesn’t use reflection. It doesn’t require passing raw field names to ignore. Instead, it allows implementing equals ignoring fields in a type-safe and easy-to-refactor way.

Since we’re not using any additional libraries, we can easily adopt it in our domain code too:

/**
 * Decides if the static HTML page representing specific blog post
 * should be rendered again. If there were no client-facing changes,
 * we want to avoid unnecessary work.
 */
fun BlogPost.requiresRendering(previous: BlogPost): Boolean {
    return this != previous.copy(
        updatedAt = this.updatedAt,
        updatedBy = this.updatedBy
    )
}

That’s another beautiful face of the Kotlin data classes!

Limitations

Of course, the described approach creates an extra object each time. In the vast majority of cases, it shouldn’t really matter. Yet, those being cautious about their objects allocation may prefer to avoid the suggested approach.

Additionally, in specific situations, reflection-based solutions can still be useful. If we want to skip a nested field, the native solution becomes a bit cumbersome:

val post3 = post1.copy(
    // same ID, different name
    author = User(id = post1.author.id, name = "different")
)
    
Assertions.assertThat(post3)
    .usingRecursiveComparison()
    .ignoringFields("author.name") // nested field!
    .isEqualTo(post1)

// "native" solution requires constructing the User object explicitly...
// ... but we can follow the same pattern once more!
val equalsIgnoringAuthorName = post1 == post3.copy(
    author = post3.author.copy(name = post1.author.name)
)

The deeper the nesting, the more code we have to write ourselves. Thus, for more sophisticated scenarios, the reflection-based approach may feel more convenient.

Last but not least, native solution relies on the standard, all-fields .equals(). Using a custom implementation anywhere in the hierarchy (including the nested objects) may require a different approach.

Mike Kowalski

Software engineer believing in craftsmanship and the power of fresh espresso. Writing in & about Java, distributed systems, and beyond. Mikes his own opinions and bytes.