Operator Overloading, Kept on a Leash
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.
This post 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) — calls plus
a + b is compiled to a.plus(b). The mapping is fixed: + is always plus, - is minus, * is times. You can’t invent <=> or change what + parses as — you can 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 the [] syntax, which is how map[key] and list[i] work in the first place:
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 — the trick behind treating an object as a callable:
class Adder(val by: Int) {
operator fun invoke(x: Int) = x + by
}
val add5 = Adder(5)
add5(10) // 15 — calls invoke
contains backs the in keyword, and compareTo backs the comparison operators — the same Comparable wiring the equality post covered, where implementing compareTo makes < and > work.
if (point in rectangle) { /* ... */ } // calls rectangle.contains(point)
When it’s worth it
The test is whether the symbol’s conventional meaning matches what you’re doing. + on a money or vector or duration type reads better than .plus() because addition is genuinely what’s happening. [] on a grid or matrix is clearer than .cellAt(). These are the cases the standard library itself uses — BigDecimal, Duration, collections.
The failure mode is cleverness: overloading + to mean “register a listener” or * to mean “repeat and shuffle.” If a reader has to look up what the operator does, you’ve spent the feature’s entire budget and gotten nothing. The fixed name list keeps you from inventing nonsense; good taste keeps you from misusing the names you’re given.
Compound assignment
+= and its siblings map to functions too. For an immutable type, implementing plus is enough — a += b falls back to a = a + b. For a mutable type you can implement plusAssign to modify in place:
val list = mutableListOf(1, 2)
list += 3 // calls plusAssign — mutates the list
This is exactly why += mutates a MutableList but reassigns a read-only val list: two different functions behind one symbol.
Unary and range operators
The unary operators have names as well, and .. — the range operator you’ve used since control flow — is just rangeTo:
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 — which is 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)
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)
}
val total = Money(500) + Money(250) // Money(750)
val triple = Money(100) * 3 // Money(300)
total > triple // true — compareTo backs >
Every operator here maps to an obvious meaning — which is the bar to clear before reaching for the feature at all.
Final thoughts
Kotlin’s bet is that operator overloading is dangerous only when it’s 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 that’s safe in practice — useful for the handful of types where + or [] carries obvious meaning, and awkward enough to abuse that most code never tries.
One post left, and it’s the big one — the feature that pulled many teams to Kotlin in the first place: coroutines, asynchronous code that reads like it’s synchronous.