Groovy @CompileStatic: Where Static Compilation Earns Its Keep
Groovy's dynamic dispatch is a feature until a renamed method surfaces in production. A look at what @CompileStatic changes, a one-command verification, and where to draw the boundary.
12 Jun 2026, 23:33 UTC

A Groovy service can compile cleanly, pass its tests, and still fail on the first request that reaches a renamed method. In default dynamic mode, Groovy resolves every method call at runtime by consulting the metaclass — the per-class table that decides what a call like item.total() actually executes. That late binding is why many teams choose Groovy, and it is also why a typo or a bad argument type can hide in a rarely exercised branch until production traffic finds it.
The fix is not to write Java in Groovy syntax. @CompileStatic works best as a boundary tool: apply it where a runtime surprise is unacceptable — shared modules, public APIs, hot paths — and leave the genuinely dynamic parts dynamic. Here is what the annotation changes, a worked example you can verify in about a minute, and the trade-offs to know before annotating anything.
What @CompileStatic actually changes
@CompileStatic lives in groovy.transform and can annotate a class, a single method, or even a closure. It tells the compiler to do two things it normally defers to runtime:
- Type checking at compile time. Method calls, property access, and assignments are resolved against declared types while compiling. A call to a method that does not exist becomes a compile error instead of a runtime failure.
- Static dispatch. Instead of emitting calls that route through the metaclass at runtime, the compiler emits direct bytecode invocations of the kind
javacproduces. The per-call metaclass lookup disappears from the annotated code.
If you want only the first benefit, Groovy also offers @TypeChecked: it performs the same compile-time checks but keeps dynamic dispatch, so runtime metaprogramming still works. The three modes compare like this:
| Mode | Type errors caught at compile time | Call dispatch | Runtime metaclass changes honored |
|---|---|---|---|
| Default (dynamic) | No | Runtime, via metaclass | Yes |
@TypeChecked | Yes | Runtime, via metaclass | Yes |
@CompileStatic | Yes | Resolved at compile time | No |
One caveat up front: the type checker's behavior is version-sensitive across Groovy 2.x, 3.x, and 4.x. Inference has improved over time, so code the checker rejects under 2.x may compile under 4.x. Pin your expectations to the version in your build and re-verify after any Groovy upgrade.
Worked example: turning a runtime failure into a compile failure
Create a small file, OrderService.groovy:
import groovy.transform.CompileStatic
@CompileStatic
class OrderService {
String label(int quantity) {
return quantity.toUpperCase() // int has no such method
}
}
With Groovy installed and on your PATH, compile it from the directory containing the file:
groovyc OrderService.groovy
No special permissions are needed — just a JDK and the Groovy distribution. The expected result: compilation fails, with the error pointing at the toUpperCase() call. Now delete the @CompileStatic line and run the command again. This time it compiles, because dynamic Groovy defers method resolution to runtime; the failure only appears when label() is actually called, as Groovy's missing-method failure (MissingMethodException). That before-and-after is the whole argument for the annotation in one command.
In a Gradle project the same check runs through the compileGroovy task. Run ./gradlew compileGroovy from the project root; if an annotated file has a type problem, the build fails at that task instead of packaging the bug.
Because the annotation is granular, you can be surgical inside a single class:
class CheckoutFlow {
// stays dynamic: assembled from config, calls injected handlers
def render(Map model) {
// ...
}
@CompileStatic
BigDecimal tax(BigDecimal net, BigDecimal rate) {
net * rate
}
}
Here tax() gets checked and statically dispatched, while render() keeps full dynamic flexibility, including any metaprogramming it relies on.
Where the boundary usually belongs
A workable rule of thumb for a server-side codebase:
- Annotate shared modules and public APIs. These are the surfaces where one signature mistake propagates to every caller; a compile error is cheaper than a support ticket.
- Annotate hot paths. Request handlers and loops over collections are where dynamic dispatch overhead adds up; static compilation removes it.
- Annotate thin-tested code that runs in production. The type checker is a free second reviewer for paths your tests never reach.
- Leave dynamic what is genuinely dynamic: build and ops scripts, one-off glue, and anything leaning on
methodMissing,ExpandoMetaClass, or loosely typed maps parsed from JSON.
What static compilation will not save you from
- Runtime metaprogramming. Methods added through the metaclass or handled by
methodMissingare invisible to the static checker — calls to them fail compilation, and metaclass changes made at runtime are not honored at statically compiled call sites. This is the most common reason an annotation has to come back off. - Bad values from untyped edges. Static compilation inserts casts based on declared types. A value pulled from a raw-typed Java API or an untyped map can still fail at runtime when the real type does not match what the code assumed.
- Expressiveness. Some idiomatic patterns — spread operators over untyped collections, loose Groovy truth on unusual types — need explicit types or rework under static compilation. Over-annotating leaves you writing verbose, Java-shaped Groovy with none of the convenience that justified the language choice.
A cheap way to start
Pick one service class, put @CompileStatic at the class level, and run ./gradlew compileGroovy. Read the errors as an audit: each one marks a place where your code depends on dynamic behavior. For every error, decide deliberately — add the missing types, keep that method dynamic by annotating around it, or fix a latent bug the checker just found. However each decision goes, you will know exactly where your codebase's dynamic habits live, and that knowledge holds even for the code you choose to leave dynamic.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.