Operator Overloading, Kept on a Leash
Kotlin allows operator overloading but only through a fixed set of conventionally-named functions: plus, get, invoke, contains, compareTo, iterator, rangeTo — plus the plusAssign ambiguity and how in reverses its operands.
Java’s designers refused operator overloading on purpose. They’d watched C++ programmers redefine + to mean something clever and unreadable and decided the feature caused more harm than good, so in Java, a + b means numeric addition or string concatenation, and nothing else, ever. Kotlin reopens the door but fits it with a lock: you can overload operators, but only a fixed set, and only by implementing functions with specific names. The constraint is the point. Everything here was run against Kotlin 2.4.10.
This chapter follows the scope functions. It’s a feature to use sparingly, which is exactly how Kotlin’s design nudges you.
Operators map to function names
There’s no “define a new operator” in Kotlin. Each built-in operator corresponds to a function with a reserved name, marked operator. Implement the function and the symbol works on your type:
data class Vec(val x: Int, val y: Int) {
operator fun plus(other: Vec) = Vec(x + other.x, y + other.y)
}
Vec(1, 2) + Vec(3, 4) // Vec(4, 6) — compiled to Vec(1,2).plus(Vec(3,4))
The mapping is fixed: + is always plus, - is minus, * is times, / is div, % is rem. You can’t invent <=> or change what + parses as — only give the existing + a meaning for your type.
The ones you’ll actually use
A handful come up far more than the arithmetic operators.
Indexing — get and set wire up [], which is how map[key] and list[i] work in the first place, and both take any number of indices:
class Grid(private val cells: IntArray, val width: Int) {
operator fun get(x: Int, y: Int) = cells[y * width + x]
operator fun set(x: Int, y: Int, value: Int) { cells[y * width + x] = value }
}
grid[2, 3] = 9 // calls set(2, 3, 9)
invoke lets an instance be called like a function:
class Adder(val by: Int) { operator fun invoke(x: Int) = x + by }
Adder(5)(10) // 15 — calls invoke
contains backs the in keyword, with a twist worth internalizing: a in b compiles to b.contains(a), so the operands reverse, because in asks “is a inside b” and the collection is the receiver:
class Vec(val x: Int, val y: Int) { operator fun contains(n: Int) = n == x || n == y }
2 in Vec(2, 5) // true — calls Vec(2,5).contains(2)
compareTo backs <, >, <=, >= through Comparable, the same wiring the equality chapter covered. And componentN is what makes a type destructurable — another convention hiding behind familiar syntax.
Compound assignment, and its one ambiguity
+= and its siblings map to functions too, and there are two ways to satisfy them. For an immutable type, implementing plus is enough — a += b desugars to a = a + b. For a mutable type you implement plusAssign to mutate in place:
val list = mutableListOf(1, 2)
list += 3 // calls plusAssign — mutates the list, no reassignment
This is exactly why += mutates a MutableList but reassigns a read-only val list — two different functions behind one symbol. The trap: if a mutable type defines both plus and plusAssign, then a += b is ambiguous (it could reassign or mutate) and the compiler rejects it. Pick one — plusAssign for a mutable container, plus for a value type — not both.
Unary, increment, and range operators
The unary operators have names (unaryMinus, unaryPlus, not), ++/-- map to inc/dec, and .. — the range operator you’ve used since control flow — is rangeTo, with the newer ..< mapping to rangeUntil:
data class Cents(val n: Int) {
operator fun unaryMinus() = Cents(-n) // -cents
operator fun rangeTo(other: Cents) = n..other.n // cents..cents
}
Making a type iterable
Give a type an iterator operator function and it works in a for loop — how your own collection-like types become first-class citizens of the language:
class Tree(private val nodes: List<String>) {
operator fun iterator() = nodes.iterator()
}
for (node in Tree(listOf("a", "b"))) println(node)
The one you can’t overload as an operator
== is deliberately not on the list. It maps to equals, but equals is an ordinary overridable member (from Any), not an operator function you declare — which is why the equality chapter talked about overriding equals, not defining an operator. Kotlin routes == through equals with a built-in null check; you don’t get to redefine that plumbing, only the equals it calls.
A worked example
A small money type is the textbook case — addition, scaling, and comparison all read as plain arithmetic:
data class Money(val cents: Int) : Comparable<Money> {
operator fun plus(other: Money) = Money(cents + other.cents)
operator fun times(n: Int) = Money(cents * n)
override fun compareTo(other: Money) = cents.compareTo(other.cents)
}
Money(500) + Money(250) // Money(750)
Money(100) * 3 // Money(300)
Money(750) > Money(300) // true — compareTo backs >
Every operator maps to an obvious meaning, which is the bar to clear before reaching for the feature.
Final thoughts
Kotlin’s bet is that operator overloading is dangerous only when unconstrained, so it constrains it: a closed set of operators, each tied to a conventionally-named function, no custom symbols. That turns a feature Java feared into one safe in practice: useful for the handful of types where + or [] carries obvious meaning, and awkward enough to abuse that most code never tries. The details that bite are the operand reversal in in, the plus/plusAssign either-or, and that == routes through equals rather than an operator you declare.
One chapter left, and it’s the big one — the feature that pulled many teams to Kotlin: coroutines, asynchronous code that reads like it’s synchronous.
Practice: reinforce this with the companion workbook — short, click-to-reveal problems.
Comments