Groovy @CompileStatic: Adding Static Compilation Without Breaking Dynamic Code
Introduce @CompileStatic into an existing Groovy codebase one class at a time: read the compile errors as a map of dynamic dependencies, isolate the rest with @CompileDynamic, and verify the bytecode.
24 Aug 2026, 12:35 UTC

Dynamic Groovy resolves method calls at runtime. That is what makes def, metaClass and duck typing work, and it is also why a misspelled method name can survive the build and fail later as a MissingMethodException. @CompileStatic moves that resolution to compile time and emits direct JVM calls instead of dynamic call sites.
The useful takeaway: you do not have to choose between dynamic and static Groovy for an entire project. Annotate one class, let the compiler list every dynamic dependency it finds, and isolate the genuinely dynamic parts with @CompileDynamic.
What static compilation actually changes
- Type checking happens at compile time: unknown methods, wrong argument types and bad assignments fail the build.
- Call sites become direct JVM invocations rather than cached dynamic dispatch, which removes per-call dispatch overhead. The size of the win depends on how call-heavy the code is; it is not a fixed multiplier.
- Dynamic features —
metaClassassignment,ExpandoMetaClass,methodMissing,propertyMissingand duck-typed calls ondefvariables — are not available inside the annotated scope.
Prerequisites
- Groovy 3 or 4. The annotation has existed since Groovy 2.0, but bytecode generation around
invokedynamicchanged across major versions, so check your version's release notes. - A build that compiles Groovy: Gradle with the
groovyplugin, or Maven withgmavenplusorgroovy-maven-plugin. No extra dependency is required;@CompileStaticships ingroovy.transform. - A clean build you can run repeatedly, and the ability to revert source changes.
Step 1: annotate one leaf class, not the project
Pick a class with no metaprogramming and no DSL entry points. A data-holding or calculation class is a good first candidate.
// src/main/groovy/example/Invoice.groovy
package example
import groovy.transform.CompileStatic
@CompileStatic
class Invoice {
String customer
BigDecimal amount
BigDecimal withTax(BigDecimal rate) {
return amount * (1 + rate)
}
String summary() {
return "${customer}: ${amount.setScale(2)}"
}
}
Run the compile task from the project root, for example ./gradlew compileGroovy or mvn compile. Expected result: the build succeeds, or it fails with an error naming the class, the line and the method or property the compiler could not resolve. That error list is the point of the exercise — it is an inventory of where the class was relying on runtime dispatch.
Step 2: read the first errors as a map of dynamic dependencies
A simple typo is the easy case. Given this class, the compiler reports that no method toUpprCase() exists on String:
@CompileStatic
class Greeter {
String greet(String name) {
return "Hello, " + name.toUpprCase()
}
}
The harder case is code that is correct dynamically but invisible statically. A class that resolves properties through propertyMissing has no declared title property, so a statically compiled caller cannot see it:
class Row {
Map data = [:]
def propertyMissing(String name) { data[name] }
}
@CompileStatic
class Report {
String render(Row row) {
return row.title // resolved dynamically at runtime, invisible to the compiler
}
}
You have two honest fixes: add a typed accessor to Row (such as String getTitle() { data.title as String }), or keep the dynamic lookup but confine it to a small method annotated @CompileDynamic.
Step 3: isolate the dynamic edge with @CompileDynamic
import groovy.transform.CompileDynamic
import groovy.transform.CompileStatic
@CompileStatic
class Report {
String render(Row row) {
return titleOf(row)
}
@CompileDynamic
private String titleOf(Row row) {
return row.title // dynamic property access, isolated here
}
}
@CompileDynamic restores dynamic dispatch for that method only. Keeping a typed return value at the boundary means the rest of the class still benefits from static checking. The annotation can also be placed at the top of a script file; confirm the behavior on your Groovy version before relying on it in a build.
Checking that static compilation actually happened
- Compile, then inspect a class file:
javap -c -p build/classes/groovy/main/example/Invoice.class, run from the project root.javapships with the JDK and needs no extra install. Look forinvokevirtualorinvokestaticon your own methods instead of call-site bootstrapping. - Do not treat the mere presence of
invokedynamicas proof that static compilation failed. On Java 9 and later, string concatenation and lambdas also compile toinvokedynamicthroughStringConcatFactoryandLambdaMetafactory. Groovy's dynamic call sites instead reference bootstrap targets inorg.codehaus.groovy.runtime.callsiteorIndyInterface. - If you measure timing, warm the JVM and loop long enough to trigger JIT compilation. A single cold run measures class loading, not dispatch cost, and a microbenchmark is not a project-wide guarantee.
@TypeChecked versus @CompileStatic
| Annotation | Compile-time type errors | Runtime dispatch | Reasonable use |
|---|---|---|---|
@TypeChecked | Yes | Dynamic | You want early errors but must keep metaClass or duck typing |
@CompileStatic | Yes | Direct JVM calls | The class has no dynamic dependencies and sits on a hot path |
| No annotation | No | Dynamic | Prototypes, DSLs, code that manipulates the metaclass |
Recovery and rollback
Because this changes source and build output, rollback means removing the annotation and recompiling. You rarely need to roll back everything at once:
- If one class will not compile and cannot be refactored now, remove
@CompileStaticfrom that class only. The rest of the migration stands. - If a runtime failure appears after static compilation — often a third-party Groovy library that expected a dynamic signature — revert that class, then write a test that reproduces the failure before retrying.
- If a method needs metaprogramming, move it behind a
@CompileDynamicboundary rather than abandoning the class.
Limitations worth knowing before you commit
- Compilation time can increase for large classes, and satisfying the type checker may require explicit casts or typed accessors that dynamic Groovy let you skip.
- Static compilation does not make code correct. It surfaces type errors; null handling and logic errors remain runtime concerns.
- Third-party libraries that expose Groovy interfaces built on dynamic dispatch may behave differently. Test the integration rather than assuming it is unaffected.
- A system property sometimes cited for strict compilation,
groovy.compiler.strict, is not required for@CompileStaticto work. Verify whether it exists in your Groovy version before wiring it into CI.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.