Operator Overloading
Operator overloading in Kotlin lets your own types respond to built-in operators like +, -, ==, [], and () by giving them meaningful, type-specific behavior. Instead of writing vector.add(other), you can write vector + other — the code reads the way the domain actually works. Kotlin makes this safe and explicit: you can only overload a fixed set of operators, and only by writing a function named exactly right and marked with the operator keyword, so there is no hidden magic the compiler can’t check.
Overview / How it works
Every operator in Kotlin — +, -, *, ==, [], (), and others — is defined in terms of a specific function name by convention. When the compiler sees a + b, it does not do arithmetic magic; it rewrites the expression as a function call: a.plus(b). If a member function or extension function named plus exists on the type of a, takes a matching parameter, and is marked with the operator modifier, the expression compiles. If no such function exists, or it exists but is missing the operator keyword, you get a compile error. This is why Kotlin’s null-safety and type-checking guarantees extend naturally to operators: a + b is exactly as type-safe as a.plus(b), because it literally is that call under the hood.
The operator modifier is not decorative — it is a contract with the compiler that this function is meant to be invoked through operator syntax, not just called by name. This matters because Kotlin resolves operators purely by naming convention plus that modifier; there’s no separate “operator overload” syntax like in C++. A function named plus without the modifier is just a regular method — you can still call a.plus(b) directly, but a + b will not compile.
Because operator functions are ordinary functions, everything you already know about Kotlin functions still applies: they participate in overload resolution, they can be extension functions on types you don’t own, they respect open/override for polymorphism, and their return type can be anything — it doesn’t have to be the same type as the receiver.
Syntax
The general shape is a function declaration with the exact conventional name, marked operator:
operator fun plus(other: Point): Point
operator fun get(index: Int): Char
operator fun invoke(x: Int): String
operator fun compareTo(other: Money): Int
operator fun contains(item: String): Boolean
Each overloadable operator maps to a specific function name and signature shape:
| Operator | Function name | Typical use |
|---|---|---|
+ |
plus |
Addition-like combination |
- |
minus |
Subtraction-like difference |
* |
times |
Scaling / multiplication |
/ |
div |
Division |
% |
rem |
Remainder |
+=, -=, … |
plusAssign, minusAssign, … |
In-place mutation (falls back to plus/minus + reassignment if not defined) |
unary - |
unaryMinus |
Negation |
unary + |
unaryPlus |
Identity / sign marker |
! |
not |
Logical negation |
++, -- |
inc, dec |
Increment / decrement |
<, >, <=, >= |
compareTo |
Ordering (usually via Comparable) |
[] read |
get |
Indexed access |
[] write |
set |
Indexed assignment |
() |
invoke |
Calling an object like a function |
in |
contains |
Membership test |
.. |
rangeTo |
Building a range |
for (x in y) |
iterator |
Iteration support |
Note that == and != are not in this table the same way — they always call equals(other: Any?): Boolean, which is inherited from Any and does not need the operator keyword when you override it, because it’s already declared as an operator on Any. The same applies to compareTo when it comes from implementing the Comparable<T> interface: the interface already marks it operator, so your override inherits that automatically.
Examples
Example 1: arithmetic on a value type. A 2D vector is a natural fit for +, -, scalar *, and unary -.
data class Vector2D(val x: Int, val y: Int) {
operator fun plus(other: Vector2D): Vector2D = Vector2D(x + other.x, y + other.y)
operator fun minus(other: Vector2D): Vector2D = Vector2D(x - other.x, y - other.y)
operator fun times(scalar: Int): Vector2D = Vector2D(x * scalar, y * scalar)
operator fun unaryMinus(): Vector2D = Vector2D(-x, -y)
}
fun main() {
val a = Vector2D(2, 3)
val b = Vector2D(4, 1)
println(a + b)
println(a - b)
println(a * 3)
println(-a)
}
Output:
Vector2D(x=6, y=4)
Vector2D(x=-2, y=2)
Vector2D(x=6, y=9)
Vector2D(x=-2, y=-3)
Vector2D is a data class, so toString() is generated for free and prints the fields exactly as shown. Each operator function returns a brand-new Vector2D rather than mutating a or b — that’s idiomatic, since Vector2D‘s fields are val and the type itself is meant to be an immutable value.
Example 2: ordering with Comparable. Implementing Comparable<T> and overriding compareTo unlocks <, >, <=, and >= for free.
data class Money(val cents: Int) : Comparable<Money> {
override fun compareTo(other: Money): Int = cents.compareTo(other.cents)
override fun toString(): String = "\$${cents / 100}.${(cents % 100).toString().padStart(2, '0')}"
}
fun main() {
val price1 = Money(500)
val price2 = Money(750)
println(price1 < price2)
println(price1 > price2)
println(if (price1 < price2) price1 else price2)
}
Output:
true
false
$5.00
price1 < price2 compiles to price1.compareTo(price2) < 0. Because Money delegates to Int.compareTo, all four comparison operators work consistently without writing four separate functions.
Example 3: indexing with get and set. A small grid class can use square-bracket syntax with two indices.
class Grid(private val width: Int, private val height: Int) {
private val cells = IntArray(width * height)
operator fun get(x: Int, y: Int): Int = cells[y * width + x]
operator fun set(x: Int, y: Int, value: Int) {
cells[y * width + x] = value
}
}
fun main() {
val grid = Grid(3, 3)
grid[1, 1] = 42
grid[0, 0] = 7
println(grid[1, 1])
println(grid[0, 0])
println(grid[2, 2])
}
Output:
42
7
0
grid[1, 1] = 42 compiles to grid.set(1, 1, 42), and grid[1, 1] as a value compiles to grid.get(1, 1). The operator fun get can take any number of parameters, which is how multi-dimensional indexing works without special-casing.
Example 4: extension operators for types you don’t own. You can add an operator to a type without modifying its source, by writing an operator extension function — useful for making an operator work in reverse order.
data class Vector2D(val x: Int, val y: Int)
operator fun Int.times(v: Vector2D): Vector2D = Vector2D(this * v.x, this * v.y)
fun main() {
val a = Vector2D(2, 3)
val doubled = 2 * a
println(doubled)
}
Output:
Vector2D(x=4, y=6)
Here Vector2D has no times member at all; 2 * a resolves to the extension function Int.times(Vector2D) because the left-hand side (2) is an Int. This is exactly how the standard library lets you write things like 3.days or scale built-in numeric types against your own classes.
How it works step by step
When the compiler encounters an operator expression like a + b, it performs the following resolution, entirely at compile time:
- It rewrites
a + bto the calla.plus(b)(using the conventional name for+). - It searches for a function named
plusthat is a member ofa‘s type, or an extension function whose receiver type matchesa‘s type and whose parameter matchesb‘s type. - The candidate function must be marked
operator(directly, or inherited from an interface/superclass that already marks itoperator, as withComparable.compareTo). - If no matching, correctly-marked function exists, compilation fails right there — there is no runtime fallback or reflection involved.
- If found, the expression becomes a normal function call, subject to normal virtual dispatch: if
plusisopenand overridden in a subclass, the overridden version runs, just like any other method call. - For compound assignment (
a += b), the compiler first looks forplusAssign(an in-place mutation returningUnit). If that’s not defined butplusis, it rewritesa += basa = a.plus(b)instead — which only compiles ifais avar.
This is why operator overloading never introduces runtime surprises beyond what an equivalent explicit function call could do: a + b and a.plus(b) are, after compilation, the exact same bytecode.
Common Mistakes
1. Forgetting the operator modifier
class Point(val x: Int, val y: Int) {
fun plus(other: Point): Point = Point(x + other.x, y + other.y)
}
fun main() {
val p1 = Point(1, 2)
val p2 = Point(3, 4)
val p3 = p1 + p2
println(p3)
}
This fails to compile: plus exists and has the right signature, but without operator the compiler refuses to treat + as a call to it. The fix is one keyword:
class Point(val x: Int, val y: Int) {
operator fun plus(other: Point): Point = Point(x + other.x, y + other.y)
override fun toString(): String = "Point(x=$x, y=$y)"
}
fun main() {
val p1 = Point(1, 2)
val p2 = Point(3, 4)
println(p1 + p2)
}
Output:
Point(x=4, y=6)
2. Misunderstanding += on val vs. var collections
fun main() {
val numbers: List<Int> = listOf(1, 2, 3)
numbers += 4
println(numbers)
}
A read-only List has no plusAssign, so numbers += 4 falls back to numbers = numbers.plus(4) — but numbers is a val, and a val reference can never be reassigned, so this is a compile error. Remember: val only prevents reassigning the reference, not mutating what it points to. The fix depends on what you actually want:
fun main() {
val numbers: MutableList<Int> = mutableListOf(1, 2, 3)
numbers += 4
println(numbers)
var readOnly: List<Int> = listOf(1, 2, 3)
readOnly += 4
println(readOnly)
}
Output:
[1, 2, 3, 4]
[1, 2, 3, 4]
A val MutableList works because MutableList defines plusAssign, which mutates the existing list in place — the val reference itself never changes. A var List works too, but by reassigning the variable to a brand-new list produced by plus.
3. Overriding equals without hashCode
This one is sneaky because it compiles fine — it’s a logic bug, not a syntax error:
class Coin(val cents: Int) {
override fun equals(other: Any?): Boolean {
if (other !is Coin) return false
return cents == other.cents
}
}
fun main() {
val a = Coin(25)
val b = Coin(25)
println(a == b)
val seen = HashSet<Coin>()
seen.add(a)
println(seen.contains(b))
}
Output:
true
false
a == b is true because equals was overridden. But HashSet.contains first checks hashCode() to find the right bucket, and Coin never overrode it, so a and b get different (identity-based) hash codes — the set looks in the wrong bucket and reports false, even though the objects are “equal.” The equals/hashCode contract requires that equal objects produce equal hash codes. The simplest fix is to stop writing this by hand and use a data class, which generates both consistently:
data class Coin(val cents: Int)
fun main() {
val a = Coin(25)
val b = Coin(25)
println(a == b)
val seen = HashSet<Coin>()
seen.add(a)
println(seen.contains(b))
}
Output:
true
true
Best Practices
- Only overload an operator when its meaning is obvious and matches real-world convention (
+for combining vectors, not for something unrelated like “merge configs, discarding conflicts silently”). - Prefer
data classover hand-writtenequals/hashCode/toString— it’s correct by construction and gives youcopyand destructuring too. - Keep arithmetic-style operators (
plus,minus,times) free of side effects — they should return a new value, not mutate the receiver. Reserve mutation for the*Assignfamily. - If you override
equalsmanually for any reason, always overridehashCodeto match. - Use extension operator functions to add operator support to types you don’t own (standard library types, types from a dependency) instead of wrapping them in a new class.
- Prefer
valparameters and return types that are also immutable value types for arithmetic operators, so results can’t be mutated out from under callers. - Don’t overload an operator just because you can — if the meaning isn’t immediately clear from the operator symbol, a well-named function (
combineWith,scaledBy) is more readable than a cryptic operator.
Practice Exercises
- Add an
operator fun div(scalar: Int): Vector2Dto theVector2Dclass from Example 1, soa / 2works. Test it with a vector whose coordinates don’t divide evenly and check how integer division rounds. - Give the
Moneyclass from Example 2 anoperator fun contains(range: IntRange): Boolean-style check (or simpler: acontainsoperator on a newBudgetclass wrapping a min/maxMoney) so you can writeMoney(600) in budget. - Write a
Greeterclass with anoperator fun invoke(name: String): Stringthat returns a greeting, so thatval hello = Greeter(); println(hello("Kotlin"))prints a message. Expected output shape: a single greeting line containing"Kotlin".
Summary
- Operators like
+,==,[], and()are syntactic sugar for calls to conventionally-named functions (plus,equals,get/set,invoke). - A function must be named exactly right and marked
operatorto back an operator — otherwise the compiler rejects the expression, with no runtime fallback. compareTo(viaComparable) andequals(viaAny) are already markedoperatorin their base declarations, so overrides don’t need to repeat the keyword.+=/-=prefer aplusAssign/minusAssignthat mutates in place; otherwise they fall back to reassignment viaplus/minus, which requires avar.- Extension functions can add operators to types you don’t own, including making an operator work with the receiver on either side.
- Always keep
equalsandhashCodeconsistent — preferdata classso the compiler generates both correctly. - Overload operators only when the meaning is genuinely obvious; otherwise a named function communicates intent better.
