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 + b to the call a.plus(b) (using the conventional name for +).
  • It searches for a function named plus that is a member of a‘s type, or an extension function whose receiver type matches a‘s type and whose parameter matches b‘s type.
  • The candidate function must be marked operator (directly, or inherited from an interface/superclass that already marks it operator, as with Comparable.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 plus is open and overridden in a subclass, the overridden version runs, just like any other method call.
  • For compound assignment (a += b), the compiler first looks for plusAssign (an in-place mutation returning Unit). If that’s not defined but plus is, it rewrites a += b as a = a.plus(b) instead — which only compiles if a is a var.

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 class over hand-written equals/hashCode/toString — it’s correct by construction and gives you copy and 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 *Assign family.
  • If you override equals manually for any reason, always override hashCode to 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 val parameters 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): Vector2D to the Vector2D class from Example 1, so a / 2 works. Test it with a vector whose coordinates don’t divide evenly and check how integer division rounds.
  • Give the Money class from Example 2 an operator fun contains(range: IntRange): Boolean-style check (or simpler: a contains operator on a new Budget class wrapping a min/max Money) so you can write Money(600) in budget.
  • Write a Greeter class with an operator fun invoke(name: String): String that returns a greeting, so that val 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 operator to back an operator — otherwise the compiler rejects the expression, with no runtime fallback.
  • compareTo (via Comparable) and equals (via Any) are already marked operator in their base declarations, so overrides don’t need to repeat the keyword.
  • +=/-= prefer a plusAssign/minusAssign that mutates in place; otherwise they fall back to reassignment via plus/minus, which requires a var.
  • Extension functions can add operators to types you don’t own, including making an operator work with the receiver on either side.
  • Always keep equals and hashCode consistent — prefer data class so the compiler generates both correctly.
  • Overload operators only when the meaning is genuinely obvious; otherwise a named function communicates intent better.