Groovy AST Transformations — Compile-Time Metaprogramming
In this tutorial, you will learn about Groovy AST Transformations. We cover key concepts, practical examples, and best practices to help you master this topic.
Groovy AST transformations modify the abstract syntax tree during compilation, enabling boilerplate reduction and compile-time code generation through annotations like @ToString and @Canonical.
What You'll Learn
- Built-in AST transformations
- Using @ToString, @EqualsAndHashCode, @Canonical
- @Immutable and @AutoClone
- Writing custom AST transformations
Why It Matters
AST transformations eliminate boilerplate at compile time. Gradle uses AST transformations for task configuration. DodaZIP uses custom transformations to generate serialization code for archive metadata.
Real-World Use
Data class generation (like Lombok for Groovy), compile-time validation, boilerplate reduction in frameworks, and DSL compilation optimization.
flowchart LR
A["Source Code"] --> B["AST Parser"]
B --> C["AST Transformations"]
C --> D["Modified AST"]
D --> E["Bytecode"]
C --> F["Local"]
C --> G["Global"]
style A fill:#2563eb,stroke:#2563eb,color:#fff
style B fill:#dbeafe,stroke:#2563eb,color:#1e40af
style C fill:#dbeafe,stroke:#2563eb,color:#1e40af
style D fill:#dbeafe,stroke:#2563eb,color:#1e40af
style E fill:#f1f5f9,stroke:#94a3b8,color:#64748b
Built-in Transformations
import groovy.transform.*
@ToString
@EqualsAndHashCode
@TupleConstructor
class Person {
String name
int age
String email
}
def p1 = new Person('Alice', 30, 'alice@example.com')
def p2 = new Person('Alice', 30, 'alice@example.com')
println p1 // Person(Alice, 30, alice@example.com)
println p1 == p2 // true
@Canonical
Combines @ToString, @EqualsAndHashCode, and @TupleConstructor:
import groovy.transform.Canonical
@Canonical
class Point {
int x, y
}
def p = new Point(3, 4)
println p // Point(3, 4)
@Immutable
Creates an immutable data class with read-only properties:
import groovy.transform.Immutable
@Immutable
class Config {
String host
int port
boolean ssl
}
def cfg = new Config('example.com', 443, true)
// cfg.host = 'other.com' // ERROR: cannot set immutable property
// Immutable classes are also automatically
// @ToString, @EqualsAndHashCode, @TupleConstructor
@AutoClone
Generates a clone method:
import groovy.transform.AutoClone
@AutoClone
class Document {
String title
List<String> tags
def getDisplay() { "$title [$tags]" }
}
def doc = new Document(title: 'Report', tags: ['urgent', 'draft'])
def clone = doc.clone()
clone.title = 'Copy'
println doc.display // Report [urgent, draft]
println clone.display // Copy [urgent, draft]
@Singleton
Implements the singleton pattern:
import groovy.transform.Singleton
@Singleton
class Database {
def query(String sql) {
println "Executing: $sql"
}
}
// Access via instance property
Database.instance.query('SELECT * FROM users')
@PackageScope
Makes generated methods use package-level visibility instead of public:
import groovy.transform.PackageScope
import groovy.transform.ToString
@PackageScope
@ToString
class InternalData {
String secret
}
def data = new InternalData(secret: 'classified')
println data.toString() // Can still access in same package
@Log
Adds a logger field:
import groovy.util.logging.Log
@Log
class Service {
def process() {
log.info "Processing started"
log.warning "Low memory"
log.severe "Critical error"
}
}
new Service().process()
Custom AST Transformation
import org.codehaus.groovy.transform.*
import org.codehaus.groovy.control.*
import org.codehaus.groovy.ast.*
import org.codehaus.groovy.ast.stmt.*
import org.codehaus.groovy.ast.expr.*
@GroovyASTTransformation(phase = CompilePhase.SEMANTIC_ANALYSIS)
class LogMethodCall implements ASTTransformation {
void visit(ASTNode[] nodes, SourceUnit source) {
def classNode = nodes[1]
classNode.methods.each { method ->
def oldCode = method.code
method.code = new BlockStatement([
new ExpressionStatement(
new MethodCallExpression(
new PropertyExpression(
new ClassExpression(ClassHelper.make(System)),
new ConstantStringExpression("out")
),
"println",
new ArgumentListExpression([
new ConstantStringExpression(
"Calling ${method.name}"
)
])
)
),
oldCode
] as List<Statement>, new VariableScope())
}
}
}
Common Mistakes
1. Forgetting @GroovyASTTransformation annotation
Without it, the transformation is never discovered. The annotation must be present for the compiler to find it.
2. Not registering custom transformations
Custom transformations need a META-INF/services descriptor or compilation classpath registration.
3. Overusing @Canonical
@Canonical generates tuple constructors that change with field order. Use explicit @ToString/@EqualsAndHashCode when stability matters.
4. @Immutable with mutable fields
@Immutable only protects direct field assignment. Fields holding mutable objects (List, Map) are still mutable via their own methods.
5. Transformation ordering issues
Global transformations run before local ones. Relying on results of another transformation can fail due to ordering.
Practice Questions
1. What phase does @ToString transformation run in?
Semantic Analysis phase, after the AST is fully built but before bytecode generation.
2. How does @Canonical differ from @ToString?
@Canonical combines @ToString, @EqualsAndHashCode, and @TupleConstructor in a single annotation.
3. Can you have multiple AST transformations on one class?
Yes. Multiple annotations can be applied and they compose in declaration order.
4. What makes a class @Immutable?
All fields become final, the class is final, and defensive copies are made for collection properties.
Challenge: Write a custom AST transformation that adds a Builder pattern to annotated classes.
FAQ
{{< faq question="Are AST transformations compile-time or runtime?" >}} Compile-time. They execute during compilation and modify the AST before bytecode generation. The resulting bytecode contains no trace of the transformation. {{< /faq >}}
{{< faq question="Which is better — AST transformation or runtime metaprogramming?" >}} AST transformations are faster (no runtime overhead) and type-checkable. Runtime Metaprogramming is more flexible. Use AST transformations for known patterns, MOP for dynamic needs. {{< /faq >}}
{{< faq question="Can AST transformations access annotations at compile time?" >}} Yes. Transformations read annotation parameters from the AST nodes to configure behavior. {{< /faq >}}
{{< faq question="Do AST transformations work with Java classes?" >}} No. AST transformations only apply to Groovy source files during compilation. {{< /faq >}}
{{< faq question="Are there third-party AST transformation libraries?" >}} Yes. Groovy's standard library includes many. External libraries like GroovyExtensions provide additional ones. {{< /faq >}}
Mini Project
Use built-in AST transformations to create a complete REST API model layer.
import groovy.transform.*
@Canonical
@Immutable
class User {
String id
String name
String email
}
@Canonical
@Immutable
class Product {
String sku
String name
BigDecimal price
}
@Singleton
class UserStore {
private users = [:]
User find(String id) { users[id] }
void save(User u) { users[u.id] = u }
}
// Usage
def user = new User(id: '1', name: 'Alice', email: 'alice@test.com')
UserStore.instance.save(user)
println UserStore.instance.find('1')
What's Next
Now that you understand AST transformations, proceed to Groovy's Grape dependency manager.
| Topic | Description | Link |
|---|---|---|
| Grape | Script dependency management | {{< ref "27-grape" >}} |
| Scripting | Script execution | {{< ref "25-scripting" >}} |
| Closures | Anonymous functions | {{< ref "04-closures" >}} |
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro