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.
Table of Contents
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 * 3repeats a string) - Works with both
@TypeCheckedand@CompileStatic
Operator to Method Mapping Table
| Operator | Method Name | Example |
|---|---|---|
a + b | a.plus(b) | Addition |
a - b | a.minus(b) | Subtraction |
a * b | a.multiply(b) | Multiplication |
a / b | a.div(b) | Division |
a % b | a.mod(b) | Modulo |
a ** b | a.power(b) | Power |
a | b | a.or(b) | Bitwise OR |
a & b | a.and(b) | Bitwise AND |
a ^ b | a.xor(b) | Bitwise XOR |
~a | a.bitwiseNegate() | Bitwise negate |
-a | a.negative() | Unary minus |
+a | a.positive() | Unary plus |
a[i] | a.getAt(i) | Subscript read |
a[i] = v | a.putAt(i, v) | Subscript write |
a << b | a.leftShift(b) | Left shift / append |
a >> b | a.rightShift(b) | Right shift |
a++ | a.next() | Increment |
a-- | a.previous() | Decrement |
a <=> b | a.compareTo(b) | Spaceship (compare) |
a == b | a.equals(b) | Equality |
a as T | a.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 modifythis - Always implement
hashCode()when you implementequals() - Use
@EqualsAndHashCodeand@Immutableannotations to reduce boilerplate - Return
thisfromleftShift()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
==callsequals()in Groovy, not==(reference comparison). Use.is()for reference checks - Modify
thisin 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
VectorandMatrix. - 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
doublebut 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:
+isplus(),[]isgetAt(),<<isleftShift(), and so on - Implementing
Comparablegives you<=>,<,>,<=,>=plussort()andmin()/max()for free - In Groovy,
==callsequals()— use.is()for reference comparison - Arithmetic operators should return new objects,
<<should returnthisfor chaining - Use
@EqualsAndHashCodeto automatically generateequals()andhashCode()
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.
Related Posts
Previous in Series: Groovy Type Checking – @TypeChecked and @CompileStatic
Next in Series: Groovy Testing with Spock Framework
Related Topics You Might Like:
- Groovy Type Checking – @TypeChecked and @CompileStatic
- Groovy Date and Time – Java 8+ Date API with Groovy
- Groovy Range – Sequences Made Simple
This post is part of the Groovy & Grails Cookbook series on TechnoScripts.com

No comment