Skip to content
AgileLogics
7 min read

AST Transformations

Prerequisites: Classes and objects

AST transformations are annotations that make the compiler write code for you. (AST — abstract syntax tree — is the compiler's internal model of your code.) Groovy ships dozens of them in groovy.transform, and they're one of its biggest productivity wins.

@ToString, @EqualsAndHashCode, @TupleConstructor

import groovy.transform.*

@ToString(includeNames = true)
@EqualsAndHashCode
@TupleConstructor
class Book {
    String title
    String author
    int year
}

def b1 = new Book('Dune', 'Frank Herbert', 1965)
def b2 = new Book(title: 'Dune', author: 'Frank Herbert', year: 1965)
println b1
println b1 == b2
println([b1, b2].toSet().size())
Book(title:Dune, author:Frank Herbert, year:1965)
true
1

Three annotations replaced about 40 lines of Java: a readable toString, correct equals/hashCode, and a positional constructor.

@Canonical and @Immutable

import groovy.transform.*

@Canonical            // = @ToString + @EqualsAndHashCode + @TupleConstructor
class Tag { String name; String color = 'grey' }

@Immutable(copyWith = true)
class Money {
    BigDecimal amount
    String currency
    Money plus(Money other) { new Money(amount + other.amount, currency) }
}

println new Tag('urgent')
def price = new Money(9.99, 'EUR')
println price + new Money(5, 'EUR')
println price.copyWith(currency: 'USD')
try {
    price.amount = 1
} catch (ReadOnlyPropertyException e) {
    println 'Money is immutable'
}
Tag(urgent, grey)
Money(14.99, EUR)
Money(9.99, USD)
Money is immutable

@Immutable makes all fields final, copies mutable inputs defensively, and adds equals, hashCode, toString, and constructors. copyWith creates a changed copy.

@Builder

import groovy.transform.ToString
import groovy.transform.builder.Builder

@Builder
@ToString(includeNames = true, ignoreNulls = true)
class Request {
    String url
    String method
    int timeout
}

def req = Request.builder()
    .url('https://example.com')
    .method('GET')
    .timeout(30)
    .build()
println req
Request(url:https://example.com, method:GET, timeout:30)

Behavior transforms

import groovy.transform.*

class Service {
    int calls = 0

    @Memoized
    BigInteger slowFactorial(int n) {
        calls++
        (1..n).inject(1G) { a, b -> a * b }
    }

    @Lazy List<String> config = { println '(loading config)'; ['a', 'b'] }()
}

def s = new Service()
println s.slowFactorial(25)
println s.slowFactorial(25)
println "calls: $s.calls"
println 'before config'
println s.config
println s.config.size()
15511210043330985984000000
15511210043330985984000000
calls: 1
before config
(loading config)
[a, b]
2
  • @Memoized caches a method's results per argument.
  • @Lazy computes a field the first time it's read.

@Delegate and @Singleton

import groovy.transform.*

class AuditedList {
    @Delegate List<String> items = []
    List<String> log = []
    boolean add(String s) { log << "added $s"; items.add(s) }
}

@Singleton
class Settings {
    String theme = 'dark'
}

def list = new AuditedList()
list.add('x')
list.add('y')
println list.size()
println list.contains('y')
println list.log
println Settings.instance.theme
println Settings.instance.is(Settings.instance)
2
true
[added x, added y]
dark
true

@Delegate forwards every method of List to the items field — the class gets size, contains, and the rest for free, while you override only what you need. Composition without boilerplate.

Other useful transforms

Annotation What it generates
@Sortable compareTo and comparators from properties
@AutoClone a clone() method
@MapConstructor / @NamedVariant map-based constructors / named parameters
@Log, @Slf4j a log field for a logging library
@TypeChecked, @CompileStatic compile-time type checking / static compilation
@RecordType record-like classes (see Enums and Records)

Level 3 shows how to write your own transformations.