Groovy Operator Overloading – Custom Operators for Your Classes with 10 Examples

Groovy operator overloading with 10+ examples. Learn operator-to-method mapping, Comparable, equals/hashCode, custom [] and << operators, Money class.

“When your objects can use +, -, and ==, your code stops looking like code and starts looking like math.”

Bjarne Stroustrup, The C++ Programming Language

Last Updated: March 2026 | Tested on: Groovy 5.x, Java 17+ | Difficulty: Intermediate | Reading Time: 22 minutes

Imagine writing total = price1 + price2 instead of total = price1.add(price2). Or matrix[2][3] instead of matrix.getElement(2, 3). That is what Groovy operator overloading gives you — the ability to define what +, -, *, [], <<, and other operators mean for your own classes.

Unlike Java, where operator overloading is not available to developers, Groovy maps every operator to a specific method name. If your class has a plus() method, you can use + on it. If it has a getAt() method, you can use []. This is simple, predictable, and incredibly useful.

In this tutorial, we will cover the full operator-to-method mapping table, implement all the common operators, build real-world classes like Money and Vector, and properly implement equals(), hashCode(), and Comparable. Your custom classes will feel as natural to use as built-in types.

How Operator Overloading Works in Groovy

According to the official Groovy documentation on operator overloading, Groovy maps each operator to a specific method name. When you write a + b, Groovy calls a.plus(b). When you write a[i], Groovy calls a.getAt(i). All you need to do is implement the corresponding method in your class.

Key Points:

  • Every operator maps to a specific method name — there is no special syntax
  • Just implement the method and the operator works automatically
  • Operators can return any type, not necessarily the same type as the operands
  • Groovy already overloads operators for built-in types (e.g., String * 3 repeats a string)
  • Works with both @TypeChecked and @CompileStatic

Operator to Method Mapping Table

OperatorMethod NameExample
a + ba.plus(b)Addition
a - ba.minus(b)Subtraction
a * ba.multiply(b)Multiplication
a / ba.div(b)Division
a % ba.mod(b)Modulo
a ** ba.power(b)Power
a | ba.or(b)Bitwise OR
a & ba.and(b)Bitwise AND
a ^ ba.xor(b)Bitwise XOR
~aa.bitwiseNegate()Bitwise negate
-aa.negative()Unary minus
+aa.positive()Unary plus
a[i]a.getAt(i)Subscript read
a[i] = va.putAt(i, v)Subscript write
a << ba.leftShift(b)Left shift / append
a >> ba.rightShift(b)Right shift
a++a.next()Increment
a--a.previous()Decrement
a <=> ba.compareTo(b)Spaceship (compare)
a == ba.equals(b)Equality
a as Ta.asType(T)Type coercion

10 Practical Operator Overloading Examples

Example 1: The plus() Method (+)

What we’re doing: Implementing the + operator by defining a plus() method.

Example 1: The plus() Method

class Point {
    int x, y

    Point(int x, int y) {
        this.x = x
        this.y = y
    }

    // Implement + operator
    Point plus(Point other) {
        return new Point(this.x + other.x, this.y + other.y)
    }

    String toString() { "Point($x, $y)" }
}

def p1 = new Point(3, 4)
def p2 = new Point(1, 2)

// Using the + operator -- calls p1.plus(p2)
def p3 = p1 + p2
println "$p1 + $p2 = $p3"

// Chaining works too
def p4 = p1 + p2 + new Point(10, 10)
println "Chained: $p4"

// The method is still callable directly
def p5 = p1.plus(p2)
println "Direct call: $p5"

Output

Point(3, 4) + Point(1, 2) = Point(4, 6)
Chained: Point(14, 16)
Direct call: Point(4, 6)

What happened here: When Groovy sees p1 + p2, it calls p1.plus(p2). The method returns a new Point, which means you can chain additions. This is the simplest form of operator overloading — define the method and the operator works. Notice we return a new object instead of modifying this — this is a best practice for arithmetic operators to avoid surprising side effects.

Example 2: minus(), multiply(), div()

What we’re doing: Implementing subtraction, multiplication, and division operators.

Example 2: Arithmetic Operators

class Fraction {
    int numerator
    int denominator

    Fraction(int num, int den) {
        if (den == 0) throw new ArithmeticException("Denominator cannot be zero")
        // Normalize sign
        if (den < 0) { num = -num; den = -den }
        int gcd = gcd(Math.abs(num), Math.abs(den))
        this.numerator = num / gcd
        this.denominator = den / gcd
    }

    private static int gcd(int a, int b) { b == 0 ? a : gcd(b, a % b) }

    // + operator
    Fraction plus(Fraction other) {
        return new Fraction(
            numerator * other.denominator + other.numerator * denominator,
            denominator * other.denominator
        )
    }

    // - operator
    Fraction minus(Fraction other) {
        return new Fraction(
            numerator * other.denominator - other.numerator * denominator,
            denominator * other.denominator
        )
    }

    // * operator
    Fraction multiply(Fraction other) {
        return new Fraction(numerator * other.numerator, denominator * other.denominator)
    }

    // / operator
    Fraction div(Fraction other) {
        return new Fraction(numerator * other.denominator, denominator * other.numerator)
    }

    // Unary minus (-a)
    Fraction negative() {
        return new Fraction(-numerator, denominator)
    }

    String toString() {
        denominator == 1 ? "$numerator" : "$numerator/$denominator"
    }
}

def half = new Fraction(1, 2)
def third = new Fraction(1, 3)

println "$half + $third = ${half + third}"
println "$half - $third = ${half - third}"
println "$half * $third = ${half * third}"
println "$half / $third = ${half / third}"
println "-$half = ${-half}"

// Chaining
def result = new Fraction(1, 4) + new Fraction(1, 4) + new Fraction(1, 2)
println "1/4 + 1/4 + 1/2 = $result"

Output

1/2 + 1/3 = 5/6
1/2 - 1/3 = 1/6
1/2 * 1/3 = 1/6
1/2 / 1/3 = 3/2
-1/2 = -1/2
1/4 + 1/4 + 1/2 = 1

What happened here: We built a complete Fraction class with all four arithmetic operators plus unary minus. Each operator method follows the pattern: perform the math, create a new Fraction, and let the constructor normalize it (simplify using GCD). The result 1/4 + 1/4 + 1/2 = 1 proves the normalization works correctly.

Example 3: Implementing Comparable (<=>, <, >, <=, >=)

What we’re doing: Implementing the Comparable interface to enable comparison operators.

Example 3: Comparable and Comparison Operators

class Temperature implements Comparable<Temperature> {
    double value
    String unit  // 'C' or 'F'

    Temperature(double value, String unit = 'C') {
        this.value = value
        this.unit = unit
    }

    // Convert to Celsius for comparison
    private double toCelsius() {
        return unit == 'F' ? (value - 32) * 5 / 9 : value
    }

    // Implement compareTo for <=> operator
    // This also enables <, >, <=, >= automatically!
    @Override
    int compareTo(Temperature other) {
        return Double.compare(this.toCelsius(), other.toCelsius())
    }

    String toString() { "${value}°${unit}" }
}

def boiling = new Temperature(100, 'C')
def freezing = new Temperature(0, 'C')
def bodyTemp = new Temperature(98.6, 'F')   // 37°C
def roomTemp = new Temperature(72, 'F')      // 22.2°C

// Spaceship operator (<=>)
println "100°C <=> 0°C: ${boiling <=> freezing}"     // 1 (greater)
println "0°C <=> 100°C: ${freezing <=> boiling}"     // -1 (less)
println "0°C <=> 0°C: ${freezing <=> freezing}"      // 0 (equal)

println ""

// Comparison operators (derived from compareTo)
println "100°C > 0°C: ${boiling > freezing}"
println "0°C < 100°C: ${freezing < boiling}"
println "98.6°F > 72°F: ${bodyTemp > roomTemp}"
println "98.6°F >= 37°C: ${bodyTemp >= new Temperature(37, 'C')}"

println ""

// Sorting works automatically with Comparable
def temps = [boiling, freezing, bodyTemp, roomTemp]
def sorted = temps.sort()
println "Sorted: ${sorted}"
println "Min: ${temps.min()}"
println "Max: ${temps.max()}"

Output

100°C <=> 0°C: 1
0°C <=> 100°C: -1
0°C <=> 0°C: 0

100°C > 0°C: true
0°C < 100°C: true
98.6°F > 72°F: true
98.6°F >= 37°C: true

Sorted: [0.0°C, 72.0°F, 98.6°F, 100.0°C]
Min: 0.0°C
Max: 100.0°C

What happened here: When you implement Comparable<T>, Groovy automatically gives you all the comparison operators. The <=> (spaceship) operator calls compareTo() directly. The <, >, <=, and >= operators are derived from the spaceship result. Plus, sort(), min(), and max() all work automatically.

Example 4: equals() and hashCode() (== operator)

What we’re doing: Properly implementing equals() and hashCode() to make the == operator work correctly.

Example 4: equals() and hashCode()

import groovy.transform.EqualsAndHashCode

// Manual implementation
class Color {
    int r, g, b

    Color(int r, int g, int b) {
        this.r = r; this.g = g; this.b = b
    }

    // == operator calls equals()
    @Override
    boolean equals(Object other) {
        if (this.is(other)) return true
        if (!(other instanceof Color)) return false
        Color c = (Color) other
        return r == c.r && g == c.g && b == c.b
    }

    // Always implement hashCode when you implement equals
    @Override
    int hashCode() {
        return Objects.hash(r, g, b)
    }

    String toString() { "Color($r, $g, $b)" }
}

def red1 = new Color(255, 0, 0)
def red2 = new Color(255, 0, 0)
def blue = new Color(0, 0, 255)

// == calls equals() in Groovy (not reference equality!)
println "red1 == red2: ${red1 == red2}"    // true (same values)
println "red1 == blue: ${red1 == blue}"    // false
println "red1.is(red2): ${red1.is(red2)}"  // false (different objects)

// Works in collections
def colors = [red1, blue] as Set
println "Set contains red2: ${colors.contains(red2)}"  // true (equals + hashCode)

println "\n--- Using @EqualsAndHashCode annotation ---"

// Groovy's annotation does it automatically!
@EqualsAndHashCode
class Coordinate {
    double lat, lon

    String toString() { "($lat, $lon)" }
}

def c1 = new Coordinate(lat: 40.7128, lon: -74.0060)
def c2 = new Coordinate(lat: 40.7128, lon: -74.0060)
def c3 = new Coordinate(lat: 34.0522, lon: -118.2437)

println "c1 == c2: ${c1 == c2}"
println "c1 == c3: ${c1 == c3}"
println "Same hash: ${c1.hashCode() == c2.hashCode()}"

Output

red1 == red2: true
red1 == blue: false
red1.is(red2): false
Set contains red2: true

--- Using @EqualsAndHashCode annotation ---
c1 == c2: true
c1 == c3: false
Same hash: true

What happened here: In Groovy, == calls equals(), not reference comparison (use .is() for that). This is a key difference from Java. When you override equals(), you must also override hashCode() so that objects work correctly in Sets and as Map keys. The @EqualsAndHashCode annotation generates both methods automatically based on your fields.

Example 5: The [] Operator (getAt and putAt)

What we’re doing: Implementing subscript access ([]) for reading and writing, making your class behave like a collection.

Example 5: The [] Operator

class Matrix {
    int rows, cols
    int[][] data

    Matrix(int rows, int cols) {
        this.rows = rows
        this.cols = cols
        this.data = new int[rows][cols]
    }

    // Single index: get a row
    int[] getAt(int row) {
        return data[row]
    }

    // Two indices: get a specific element (using a list)
    int getAt(List<Integer> indices) {
        return data[indices[0]][indices[1]]
    }

    // Set a specific element
    void putAt(List<Integer> indices, int value) {
        data[indices[0]][indices[1]] = value
    }

    String toString() {
        data.collect { row -> row.collect { String.format("%3d", it) }.join(" ") }.join("\n")
    }
}

def m = new Matrix(3, 3)

// Using [] to set values -- calls putAt()
m[[0, 0]] = 1; m[[0, 1]] = 2; m[[0, 2]] = 3
m[[1, 0]] = 4; m[[1, 1]] = 5; m[[1, 2]] = 6
m[[2, 0]] = 7; m[[2, 1]] = 8; m[[2, 2]] = 9

println "Matrix:"
println m

// Using [] to read values -- calls getAt()
println "\nm[1][2] = ${m[1][2]}"      // Row 1, then element 2
println "m[[0, 0]] = ${m[[0, 0]]}"    // Explicit two-index access
println "m[[2, 2]] = ${m[[2, 2]]}"

// Get an entire row
println "\nRow 1: ${m[1]}"

// Works with named configs too
class Config {
    Map<String, Object> data = [:]

    def getAt(String key) { data[key] }
    void putAt(String key, Object value) { data[key] = value }
}

def config = new Config()
config['host'] = 'localhost'
config['port'] = 8080
config['debug'] = true

println "\nConfig host: ${config['host']}"
println "Config port: ${config['port']}"

Output

Matrix:
  1   2   3
  4   5   6
  7   8   9

m[1][2] = 6
m[[0, 0]] = 1
m[[2, 2]] = 9

Row 1: [4, 5, 6]

Config host: localhost
Config port: 8080

What happened here: The getAt() method handles obj[key] reads, and putAt() handles obj[key] = value writes. You can overload these methods to accept different parameter types — integers for array-like access, strings for map-like access, or lists for multi-dimensional indexing. The Config example shows how this pattern creates dictionary-style objects.

Example 6: The << Operator (leftShift)

What we’re doing: Implementing the << operator for appending elements, a common pattern in Groovy collections.

Example 6: The << Operator

class EventLog {
    String name
    List<String> entries = []

    EventLog(String name) {
        this.name = name
    }

    // << operator for appending
    EventLog leftShift(String entry) {
        def timestamp = new Date().format('HH:mm:ss')
        entries << "[$timestamp] $entry"
        return this  // Return this for chaining
    }

    // << with a Map for structured events
    EventLog leftShift(Map event) {
        def timestamp = new Date().format('HH:mm:ss')
        entries << "[$timestamp] ${event.level}: ${event.message}"
        return this
    }

    String toString() {
        "=== $name ===\n" + entries.join("\n")
    }
}

def log = new EventLog("Application Log")

// Using << to append entries
log << "Server started"
log << "Database connected"
log << "Ready to accept connections"

// Chaining <<
log << "Request received" << "Processing..." << "Response sent"

// Using map form
log << [level: "WARN", message: "Memory usage above 80%"]
log << [level: "ERROR", message: "Connection timeout"]

println log
println "\nTotal entries: ${log.entries.size()}"

// Groovy's built-in << on lists
println "\n--- Built-in << on List ---"
def list = [1, 2, 3]
list << 4 << 5
println list

Output

=== Application Log ===
[14:30:15] Server started
[14:30:15] Database connected
[14:30:15] Ready to accept connections
[14:30:15] Request received
[14:30:15] Processing...
[14:30:15] Response sent
[14:30:15] WARN: Memory usage above 80%
[14:30:15] ERROR: Connection timeout

Total entries: 8

--- Built-in << on List ---
[1, 2, 3, 4, 5]

What happened here: The << operator calls leftShift(). By returning this, we enable chaining: log << "A" << "B" << "C". This is the same pattern Groovy uses for its built-in list append (list << item). The << operator is one of the most commonly overloaded operators in Groovy — it is intuitive for “append” or “stream into” operations.

Example 7: next() and previous() (++ and –)

What we’re doing: Implementing increment and decrement operators, which also enable use in ranges.

Example 7: Increment and Decrement

class Version implements Comparable<Version> {
    int major, minor, patch

    Version(int major, int minor, int patch) {
        this.major = major
        this.minor = minor
        this.patch = patch
    }

    // ++ operator: bump patch version
    Version next() {
        return new Version(major, minor, patch + 1)
    }

    // -- operator: decrease patch version
    Version previous() {
        return new Version(major, minor, Math.max(0, patch - 1))
    }

    @Override
    int compareTo(Version other) {
        int result = this.major <=> other.major
        if (result != 0) return result
        result = this.minor <=> other.minor
        if (result != 0) return result
        return this.patch <=> other.patch
    }

    String toString() { "$major.$minor.$patch" }
}

def v = new Version(1, 0, 0)
println "Start: $v"

// ++ operator calls next()
v++
println "After ++: $v"
v++
println "After ++ again: $v"

// -- operator calls previous()
v--
println "After --: $v"

// Since we have next(), previous(), and compareTo(),
// versions can be used in ranges!
def v1 = new Version(2, 0, 0)
def v2 = new Version(2, 0, 5)
def range = v1..v2

println "\nVersion range: ${range.toList()}"
println "Range size: ${range.size()}"
println "Contains 2.0.3: ${new Version(2, 0, 3) in range}"

Output

Start: 1.0.0
After ++: 1.0.1
After ++ again: 1.0.2
After --: 1.0.1

Version range: [2.0.0, 2.0.1, 2.0.2, 2.0.3, 2.0.4, 2.0.5]
Range size: 6
Contains 2.0.3: true

What happened here: The next() and previous() methods power the ++ and -- operators. But they also have a bonus effect: combined with compareTo(), they enable your class to work with Groovy ranges. The range v1..v2 uses next() to step from one version to the next and compareTo() to know when it has reached the end.

Example 8: The as Operator (asType)

What we’re doing: Implementing type coercion with the as keyword by overriding asType().

Example 8: The as Operator

class Distance {
    double meters

    Distance(double meters) {
        this.meters = meters
    }

    // Implement 'as' operator for type coercion
    Object asType(Class target) {
        switch (target) {
            case String:
                return "${meters}m".toString()
            case Integer:
            case int:
                return meters as int
            case Double:
            case double:
                return meters
            case Map:
                return [
                    meters: meters,
                    km: meters / 1000,
                    miles: meters / 1609.34,
                    feet: meters * 3.28084
                ]
            default:
                throw new ClassCastException("Cannot cast Distance to $target")
        }
    }

    String toString() { "${meters}m" }
}

def d = new Distance(1500.0)

// Using 'as' operator -- calls asType()
println "As String: ${d as String}"
println "As Integer: ${d as Integer}"
println "As Double: ${d as Double}"

def conversions = d as Map
println "\nConversions:"
conversions.each { unit, value ->
    println "  ${unit}: ${String.format('%.2f', value as double)}"
}

// Another example
def marathon = new Distance(42195.0)
println "\nMarathon: ${marathon as String}"
def marathonMap = marathon as Map
println "  In km: ${String.format('%.2f', marathonMap.km)}"
println "  In miles: ${String.format('%.2f', marathonMap.miles)}"

Output

As String: 1500.0m
As Integer: 1500
As Double: 1500.0

Conversions:
  meters: 1500.00
  km: 1.50
  miles: 0.93
  feet: 4921.26

Marathon: 42195.0m
  In km: 42.20
  In miles: 26.22

What happened here: The asType() method receives the target class and returns an appropriate conversion. When you write d as Map, Groovy calls d.asType(Map). This is useful for creating objects that can easily convert to different representations. The as operator is commonly used for JSON/XML conversions, unit transformations, and adapting objects to different interfaces.

Example 9: Bitwise and Logical Operators

What we’re doing: Implementing bitwise operators (|, &, ~) for a permission flags class.

Example 9: Bitwise Operators

class Permission {
    static final int READ    = 0b001
    static final int WRITE   = 0b010
    static final int EXECUTE = 0b100

    int flags

    Permission(int flags = 0) {
        this.flags = flags
    }

    // | operator: combine permissions
    Permission or(Permission other) {
        return new Permission(this.flags | other.flags)
    }

    // & operator: intersection of permissions
    Permission and(Permission other) {
        return new Permission(this.flags & other.flags)
    }

    // ~ operator: invert permissions
    Permission bitwiseNegate() {
        return new Permission(~this.flags & 0b111)
    }

    // ^ operator: toggle permissions
    Permission xor(Permission other) {
        return new Permission(this.flags ^ other.flags)
    }

    boolean hasRead() { (flags & READ) != 0 }
    boolean hasWrite() { (flags & WRITE) != 0 }
    boolean hasExecute() { (flags & EXECUTE) != 0 }

    String toString() {
        def parts = []
        if (hasRead()) parts << "READ"
        if (hasWrite()) parts << "WRITE"
        if (hasExecute()) parts << "EXECUTE"
        return parts.join("|") ?: "NONE"
    }
}

def read = new Permission(Permission.READ)
def write = new Permission(Permission.WRITE)
def execute = new Permission(Permission.EXECUTE)

// | to combine
def readWrite = read | write
println "read | write = $readWrite"

def all = read | write | execute
println "read | write | execute = $all"

// & to intersect
def common = all & readWrite
println "all & readWrite = $common"

// ~ to invert
def notRead = ~read
println "~read = $notRead"

// ^ to toggle
def toggled = all ^ write
println "all ^ write = $toggled"

println "\nPermission checks:"
println "readWrite.hasRead(): ${readWrite.hasRead()}"
println "readWrite.hasExecute(): ${readWrite.hasExecute()}"

Output

read | write = READ|WRITE
read | write | execute = READ|WRITE|EXECUTE
all & readWrite = READ|WRITE
~read = WRITE|EXECUTE
all ^ write = READ|EXECUTE

Permission checks:
readWrite.hasRead(): true
readWrite.hasExecute(): false

What happened here: Bitwise operators are perfect for flag-style classes. The | operator calls or() for combining flags, & calls and() for intersection, ~ calls bitwiseNegate() for inversion, and ^ calls xor() for toggling. This is a clean, readable way to work with permission sets, feature flags, or any combination-style data.

Example 10: Power Operator and Mixed Types

What we’re doing: Implementing the power operator (**) and showing how operators can accept different parameter types.

Example 10: Power and Mixed Types

class BigNumber {
    BigInteger value

    BigNumber(long val) {
        this.value = BigInteger.valueOf(val)
    }

    BigNumber(BigInteger val) {
        this.value = val
    }

    // + with same type
    BigNumber plus(BigNumber other) {
        return new BigNumber(this.value + other.value)
    }

    // + with integer (mixed type)
    BigNumber plus(int other) {
        return new BigNumber(this.value + BigInteger.valueOf(other))
    }

    // * with same type
    BigNumber multiply(BigNumber other) {
        return new BigNumber(this.value * other.value)
    }

    // * with integer
    BigNumber multiply(int other) {
        return new BigNumber(this.value * BigInteger.valueOf(other))
    }

    // ** power operator
    BigNumber power(int exponent) {
        return new BigNumber(this.value.pow(exponent))
    }

    // % modulo
    BigNumber mod(BigNumber other) {
        return new BigNumber(this.value.mod(other.value))
    }

    String toString() { value.toString() }
}

def a = new BigNumber(100)
def b = new BigNumber(200)

println "a + b = ${a + b}"
println "a * b = ${a * b}"
println "a + 50 = ${a + 50}"
println "a * 3 = ${a * 3}"

// Power operator
println "2 ** 10 = ${new BigNumber(2) ** 10}"
println "2 ** 20 = ${new BigNumber(2) ** 20}"
println "2 ** 64 = ${new BigNumber(2) ** 64}"

// Modulo
println "200 % 7 = ${b mod new BigNumber(7)}"

// Groovy's built-in power operator on numbers
println "\nBuilt-in: 2 ** 10 = ${2 ** 10}"
println "Built-in: 10 ** 3 = ${10 ** 3}"
println "Built-in: 2.5 ** 3 = ${2.5 ** 3}"

Output

a + b = 300
a * b = 20000
a + 50 = 150
a * 3 = 300
2 ** 10 = 1024
2 ** 20 = 1048576
2 ** 64 = 18446744073709551616
200 % 7 = 4

Built-in: 2 ** 10 = 1024
Built-in: 10 ** 3 = 1000
Built-in: 2.5 ** 3 = 15.625

What happened here: You can overload the same operator method to accept different parameter types. The plus(BigNumber) handles BigNumber + BigNumber, and plus(int) handles BigNumber + int. The ** operator calls power(), which is great for math-heavy classes. Groovy already overloads ** for built-in number types, as shown in the last few lines.

Real-World: Money Class

Let us build a production-quality Money class that demonstrates how operator overloading creates clean, intuitive financial code.

Real-World Money Class

import groovy.transform.EqualsAndHashCode

@EqualsAndHashCode(includes = ['amount', 'currency'])
class Money implements Comparable<Money> {
    final BigDecimal amount
    final String currency

    Money(BigDecimal amount, String currency = 'USD') {
        this.amount = amount.setScale(2, BigDecimal.ROUND_HALF_UP)
        this.currency = currency.toUpperCase()
    }

    Money(double amount, String currency = 'USD') {
        this(BigDecimal.valueOf(amount), currency)
    }

    private void checkCurrency(Money other) {
        if (currency != other.currency)
            throw new IllegalArgumentException(
                "Cannot operate on $currency and ${other.currency}")
    }

    // + operator
    Money plus(Money other) {
        checkCurrency(other)
        return new Money(amount + other.amount, currency)
    }

    // - operator
    Money minus(Money other) {
        checkCurrency(other)
        return new Money(amount - other.amount, currency)
    }

    // * operator (multiply by a number)
    Money multiply(BigDecimal factor) {
        return new Money(amount * factor, currency)
    }

    Money multiply(int factor) {
        return multiply(BigDecimal.valueOf(factor))
    }

    // / operator (divide by a number)
    Money div(BigDecimal divisor) {
        return new Money(amount / divisor, currency)
    }

    // Unary minus
    Money negative() {
        return new Money(-amount, currency)
    }

    // Comparison
    @Override
    int compareTo(Money other) {
        checkCurrency(other)
        return amount <=> other.amount
    }

    String toString() {
        "${currency} ${String.format('%,.2f', amount)}"
    }
}

// Create money instances
def price = new Money(29.99)
def tax = new Money(2.40)
def shipping = new Money(5.99)

println "Price:    $price"
println "Tax:      $tax"
println "Shipping: $shipping"

// Arithmetic with operators
def subtotal = price + tax
def total = subtotal + shipping
println "\nSubtotal: $subtotal"
println "Total:    $total"

// Multiply by quantity
def bulk = price * 5
println "5x price: $bulk"

// Split a bill
def perPerson = total / 3
println "Split 3 ways: $perPerson"

// Comparison
println "\nTotal > 30? ${total > new Money(30.0)}"
println "Tax < Shipping? ${tax < shipping}"

// Discount
def discount = total * 0.1
def discounted = total - discount
println "\n10% discount: $discount"
println "After discount: $discounted"

// Sorting
def prices = [new Money(99.99), new Money(15.50), new Money(42.00), new Money(7.99)]
println "\nSorted prices: ${prices.sort()}"

Output

Price:    USD 29.99
Tax:      USD 2.40
Shipping: USD 5.99

Subtotal: USD 32.39
Total:    USD 38.38

5x price: USD 149.95
Split 3 ways: USD 12.79

Total > 30? true
Tax < Shipping? true

10% discount: USD 3.84
After discount: USD 34.54

Sorted prices: [USD 7.99, USD 15.50, USD 42.00, USD 99.99]

This Money class demonstrates why operator overloading matters. Compare total = price + tax + shipping with total = price.add(tax).add(shipping) — the operator version is more readable, less error-prone, and closer to how we think about money in the real world. The currency check prevents accidentally adding USD and EUR.

Real-World: Vector Class

Another classic use case for operator overloading: mathematical vectors.

Real-World Vector Class

import groovy.transform.EqualsAndHashCode

@EqualsAndHashCode
class Vec3 implements Comparable<Vec3> {
    final double x, y, z

    Vec3(double x = 0, double y = 0, double z = 0) {
        this.x = x; this.y = y; this.z = z
    }

    // + operator
    Vec3 plus(Vec3 other) { new Vec3(x + other.x, y + other.y, z + other.z) }

    // - operator
    Vec3 minus(Vec3 other) { new Vec3(x - other.x, y - other.y, z - other.z) }

    // * operator (scalar multiply)
    Vec3 multiply(double scalar) { new Vec3(x * scalar, y * scalar, z * scalar) }

    // / operator (scalar divide)
    Vec3 div(double scalar) { new Vec3(x / scalar, y / scalar, z / scalar) }

    // Unary minus
    Vec3 negative() { new Vec3(-x, -y, -z) }

    // [] operator for component access
    double getAt(int index) {
        switch (index) {
            case 0: return x
            case 1: return y
            case 2: return z
            default: throw new IndexOutOfBoundsException("Index: $index")
        }
    }

    // Compare by magnitude
    @Override
    int compareTo(Vec3 other) {
        Double.compare(this.magnitude(), other.magnitude())
    }

    // Dot product (using * with Vec3)
    double multiply(Vec3 other) { x * other.x + y * other.y + z * other.z }

    // Magnitude
    double magnitude() { Math.sqrt(x * x + y * y + z * z) }

    // Normalize
    Vec3 normalize() {
        double mag = magnitude()
        return mag > 0 ? this / mag : new Vec3()
    }

    String toString() {
        "(${String.format('%.2f', x)}, ${String.format('%.2f', y)}, ${String.format('%.2f', z)})"
    }
}

def v1 = new Vec3(1, 2, 3)
def v2 = new Vec3(4, 5, 6)

println "v1 = $v1"
println "v2 = $v2"
println "v1 + v2 = ${v1 + v2}"
println "v1 - v2 = ${v1 - v2}"
println "v1 * 3 = ${v1 * 3}"
println "v2 / 2 = ${v2 / 2}"
println "-v1 = ${-v1}"

println "\nv1[0] = ${v1[0]}, v1[1] = ${v1[1]}, v1[2] = ${v1[2]}"

// Dot product
def dot = v1 * v2  // returns double when Vec3 * Vec3
println "\nDot product: $dot"

// Magnitude
println "Magnitude of v1: ${String.format('%.4f', v1.magnitude())}"
println "Normalized v1: ${v1.normalize()}"

// Comparison by magnitude
println "\nv1 < v2: ${v1 < v2}"
println "v1 > v2: ${v1 > v2}"

// Sorting by magnitude
def vectors = [new Vec3(10, 0, 0), new Vec3(1, 1, 1), new Vec3(3, 4, 0)]
println "Sorted by magnitude: ${vectors.sort()}"

Output

v1 = (1.00, 2.00, 3.00)
v2 = (4.00, 5.00, 6.00)
v1 + v2 = (5.00, 7.00, 9.00)
v1 - v2 = (-3.00, -3.00, -3.00)
v1 * 3 = (3.00, 6.00, 9.00)
v2 / 2 = (2.00, 2.50, 3.00)
-v1 = (-1.00, -2.00, -3.00)

v1[0] = 1.0, v1[1] = 2.0, v1[2] = 3.0

Dot product: 32.0

Magnitude of v1: 3.7417
Normalized v1: (0.27, 0.53, 0.80)

v1 < v2: true
v1 > v2: false
Sorted by magnitude: [(1.00, 1.00, 1.00), (3.00, 4.00, 0.00), (10.00, 0.00, 0.00)]

The Vec3 class shows how operator overloading turns vector math from verbose method calls into clean mathematical notation. Notice the clever trick with multiply(): v * 3.0 (scalar) returns a Vec3, but v1 * v2 (vector) returns a double (dot product). Groovy picks the right overload based on the argument type.

Best Practices and Gotchas

DO:

  • Return new objects from arithmetic operators (plus, minus, multiply) — do not modify this
  • Always implement hashCode() when you implement equals()
  • Use @EqualsAndHashCode and @Immutable annotations to reduce boilerplate
  • Return this from leftShift() to enable chaining
  • Keep operator semantics intuitive — + should mean “add” or “combine”

DON’T:

  • Overload operators with surprising behavior — + should not mean “delete”
  • Forget that == calls equals() in Groovy, not == (reference comparison). Use .is() for reference checks
  • Modify this in arithmetic operator methods — it breaks expectations about immutability
  • Overload every operator just because you can — only overload operators that make semantic sense for your class
  • Mix incompatible types without throwing clear exceptions

Performance Considerations

Operator overloading in Groovy is just method calls, so the performance characteristics are the same as any other method call:

  • Dynamic Groovy: Operator calls go through the MOP, which adds overhead. In tight loops with millions of iterations, this can be noticeable.
  • @CompileStatic: Operator calls are resolved at compile time and dispatched directly, matching Java performance. Use this for math-heavy classes like Vector and Matrix.
  • Object creation: Arithmetic operators that return new objects create garbage for the GC. For performance-critical inner loops, consider mutable variants or primitive operations.
  • BigDecimal: Groovy uses BigDecimal for decimal arithmetic by default, which is slower than double but avoids floating-point errors. For financial code, this is the right trade-off.

Conclusion

We covered the full range of Groovy operator overloading in this tutorial — from the operator-to-method mapping table, through arithmetic, comparison, subscript, and bitwise operators, to real-world implementations like Money and Vec3. Operator overloading is one of those features that, when used well, makes your code dramatically more readable.

The key insight is simple: every operator maps to a method. Implement the method and the operator works. Just remember to keep semantics intuitive, return new objects from arithmetic operators, and always pair equals() with hashCode().

For related topics, check out our post on Groovy type checking to learn about @CompileStatic which optimizes operator dispatch, and the Groovy date and time guide which shows how Groovy overloads operators on date classes.

Summary

  • Every Groovy operator maps to a method: + is plus(), [] is getAt(), << is leftShift(), and so on
  • Implementing Comparable gives you <=>, <, >, <=, >= plus sort() and min()/max() for free
  • In Groovy, == calls equals() — use .is() for reference comparison
  • Arithmetic operators should return new objects, << should return this for chaining
  • Use @EqualsAndHashCode to automatically generate equals() and hashCode()

If you also work with build tools, CI/CD pipelines, or cloud CLIs, check out Command Playground to practice 105+ CLI tools directly in your browser — no install needed.

Up next: Groovy Testing with Spock Framework

Frequently Asked Questions

How does operator overloading work in Groovy?

Groovy maps every operator to a specific method name. For example, + maps to plus(), - maps to minus(), [] maps to getAt(), and << maps to leftShift(). When you implement the corresponding method in your class, the operator automatically works. There is no special syntax — just define the method with the right name and parameter type.

Does Groovy’s == operator work like Java’s?

No. In Groovy, == calls equals(), not reference comparison. This means new String('hello') == new String('hello') returns true in Groovy but false in Java. For reference comparison (identity check), use .is() in Groovy. Always implement both equals() and hashCode() when you want == to work correctly for your custom classes.

Can I use operator overloading with @CompileStatic?

Yes, operator overloading works perfectly with @CompileStatic. In fact, @CompileStatic can make overloaded operators faster because the method calls are resolved at compile time and dispatched directly, bypassing the Meta-Object Protocol. This is ideal for math-heavy classes like vectors, matrices, or money types where performance matters.

What is the spaceship operator in Groovy?

The spaceship operator (<=>) calls compareTo() and returns -1, 0, or 1 depending on whether the left operand is less than, equal to, or greater than the right operand. When you implement Comparable on your class, Groovy automatically gives you all comparison operators (<, >, <=, >=) derived from the spaceship result. It also enables sort(), min(), and max().

Should arithmetic operators return new objects or modify the current object?

Arithmetic operators (plus, minus, multiply, div) should always return new objects rather than modifying the current object. This follows the principle of least surprise — users expect a + b to produce a new result without changing a or b. Mutation operators like leftShift (<<) should return this to enable chaining, since they are inherently about modifying the receiver.

Previous in Series: Groovy Type Checking – @TypeChecked and @CompileStatic

Next in Series: Groovy Testing with Spock Framework

Related Topics You Might Like:

This post is part of the Groovy & Grails Cookbook series on TechnoScripts.com

RahulAuthor posts

Avatar for Rahul

Rahul is a passionate IT professional who loves to sharing his knowledge with others and inspiring them to expand their technical knowledge. Rahul's current objective is to write informative and easy-to-understand articles to help people avoid day-to-day technical issues altogether. Follow Rahul's blog to stay informed on the latest trends in IT and gain insights into how to tackle complex technical issues. Whether you're a beginner or an expert in the field, Rahul's articles are sure to leave you feeling inspired and informed.

No comment

Leave a Reply

Your email address will not be published. Required fields are marked *