In Kotlin, == Is the One You Actually Want

Structural equality with == versus referential with ===, the equals/hashCode contract data classes solve, how Comparable wires the comparison operators, and two traps that survive: floating-point equals and array ==

Every Java developer has been bitten by this: two strings that are obviously equal, compared with ==, returning false. Java’s == compares references, not contents, and you were supposed to type .equals(). Kotlin swaps the defaults. == does what you meant all along, and a separate operator handles the rare case you wanted reference identity. But two sharp edges survive the swap, around floating-point and arrays, and both are the kind that pass code review and fail in production. Everything here was run against Kotlin 2.4.10.

This chapter follows extension functions.

== is structural, === is referential

In Kotlin, == means “equal in value” — it calls .equals() under the hood. The triple === means “the same object”:

val a = "hello"
val b = StringBuilder("hel").append("lo").toString()
a == b        // true  — same contents
a === b       // false — different objects

The Java string bug simply can’t happen, because == is the .equals() call. You reach for === only when you genuinely care about identity: checking against a singleton, confirming a cache returned the same instance. There’s a null-safety bonus too. == handles null on both sides without throwing, so a == b is safe when either is null, with no Objects.equals(a, b) wrapper.

The equals/hashCode contract

== is only as good as the equals it calls, and overriding equals carries a second obligation that trips people: you must override hashCode to match. Hash-based collections locate a bucket by hash first and only then check equality, so break the pairing and equal objects vanish:

class Bad(val id: Int) {
    override fun equals(other: Any?) = other is Bad && other.id == id
    // no hashCode!
}
Bad(1) in hashSetOf(Bad(1))    // false — equal objects, different default hashes

The contract is simple — equal objects must have equal hash codes — and getting it right by hand is the boilerplate data class exists to kill, generating both in sync from the primary-constructor properties:

data class Point(val x: Int, val y: Int)
Point(1, 2) == Point(1, 2)     // true

Ordering: Comparable and the comparison operators

Equality answers “are these the same?”; ordering answers “which comes first?” Kotlin wires <, >, <=, >= directly to Comparable — implement compareTo and the operators work:

class Version(val major: Int, val minor: Int) : Comparable<Version> {
    override fun compareTo(other: Version) =
        compareValuesBy(this, other, { it.major }, { it.minor })
}
Version(2, 1) > Version(2, 0)    // true

compareValuesBy compares by each selector in turn — major first, minor as tiebreaker — so you don’t hand-roll the cascade. For sorting you usually don’t need Comparable at all; sortedBy and compareBy take the selector inline:

people.sortedBy { it.age }
people.sortedWith(compareBy({ it.lastName }, { it.firstName }))

Trap one: floating-point equality has two answers

Here’s a gotcha that hides behind the clean == story, and it surprised me enough to run every line. Kotlin’s == on primitive Double/Float follows IEEE 754 — the same rules as Java’s == on doubles. But .equals() (and therefore any boxed or generic context, including a data class) follows a total ordering instead. The two disagree in exactly two places:

0.0 == -0.0            // true   (IEEE: +0 and -0 are equal)
(0.0).equals(-0.0)     // false  (total order: distinct bit patterns)

Double.NaN == Double.NaN         // false  (IEEE: NaN equals nothing, not even itself)
Double.NaN.equals(Double.NaN)    // true   (total order: NaN equals NaN)

Now the consequence that actually bites. A data class builds its equals from .equals() on each property — the total-order version — so a data class holding a NaN compares equal to itself, the opposite of what the == operator on a bare Double would tell you:

data class Box(val d: Double)
Box(Double.NaN) == Box(Double.NaN)    // true  (!)
Double.NaN == Double.NaN              // false

Neither answer is a bug; they’re two well-defined equalities, and which one fires depends on whether the value is a primitive Double or sits inside a boxed/generic/data-class context. The practical rule: don’t use floating-point values as map keys, set elements, or data class identity fields if NaN or signed zero can occur, because the equality you get won’t be the IEEE one you’re picturing. Use a tolerance comparison for logic, and keep floats out of anything hash-based.

Trap two: == on arrays compares references

The second survivor. Arrays are the one common type where == does not compare contents — an array’s equals is identity, so == is referential. Kotlin at least warns you:

val x = intArrayOf(1, 2, 3)
val y = intArrayOf(1, 2, 3)
x == y                  // false — and: warning: '==' on arrays compares only references
x.contentEquals(y)      // true  — the content comparison you meant

For nested arrays, contentDeepEquals recurses. This is a direct callback to the collections chapter’s advice to prefer List — a List compares structurally with plain ==, so reaching for List over Array makes this trap disappear entirely.

A worked example

A value type that’s both comparable and value-equal — the common shape for versions or money — and note it deliberately uses integers, sidestepping trap one:

data class SemVer(val major: Int, val minor: Int, val patch: Int) : Comparable<SemVer> {
    override fun compareTo(other: SemVer) =
        compareValuesBy(this, other, { it.major }, { it.minor }, { it.patch })
}
SemVer(1, 2, 0) == SemVer(1, 2, 0)          // true — generated equals
SemVer(1, 3, 0) > SemVer(1, 2, 9)           // true — compareTo wired to >
listOf(SemVer(1, 2, 0), SemVer(1, 0, 5)).sorted()   // ordering for free

Final thoughts

The headline is one swapped default: == means value, === means identity, and the Java string footgun is gone. Pair that with data class generating a correct equals/hashCode and the comparison operators delegating to Comparable, and equality mostly stops being a thing you get subtly wrong. The two exceptions worth carrying in your head are floating-point — where .equals and the == operator give different answers for NaN and signed zero, and a data class inherits the .equals one — and arrays, where == is referential and you want contentEquals. Both vanish if you keep floats out of identity and prefer List to Array.

Next, a small syntax feature that leans on the same data class machinery: destructuring, pulling several values out of an object in one line.

Practice: reinforce this with the companion workbook — short, click-to-reveal problems.

Comments