Groovy's @CompileStatic: Static Dispatch and Type Checking Explained
Groovy's @CompileStatic swaps runtime metaclass dispatch for Java‑like static calls and compile‑time type checking, here's what changes, a worked example, and when it's worth it.
22 Apr 2026, 14:51 UTC

The problem: Groovy is flexible, but that flexibility costs you
Every method call in dynamic Groovy goes through the runtime MetaClass dispatch machinery. The JVM asks Groovy's MetaClass layer, "who handles this call?", and the answer is resolved at runtime — which is exactly what enables Groovy's best tricks like methodMissing and runtime metaprogramming. It also means two things hurt you in practice: hot code paths pay a per‑call lookup tax, and simple mistakes like a misspelled method name or a wrong argument type only blow up when that line actually executes — possibly in production.
@CompileStatic (from groovy.transform) is the escape hatch. Annotate a class or method, and the compiler emits bytecode with Java‑style static dispatch, plus it type‑checks your code at compile time. You keep Groovy's syntax; you give up runtime dynamism in that scope.
What actually changes under the hood
Two things, and they're worth separating:
- Dispatch: calls are resolved at compile time and emitted as direct
invokevirtual/invokestaticbytecode instructions, like Java. No CallSite, no MetaClass lookup per call. - Type checking: the compiler rejects type errors — wrong argument types, unknown methods, bad assignments — at build time instead of letting them surface as
MissingMethodExceptionat runtime.
The consequence is that dynamic features stop working inside statically compiled code: methodMissing, propertyMissing, ExpandoMetaClass additions, and duck‑typed dispatch are all resolved away at compile time. If a call can't be resolved statically, it doesn't compile.
A worked example
Here’s a small class you can compile with groovyc (no build tool needed, just a Groovy installation):
import groovy.transform.CompileStatic
@CompileStatic
class Stats {
long sumSquares(List<Integer> values) {
long total = 0
for (int v : values) {
total += (long) v * v
}
total
}
}
println new Stats().sumSquares((1..1_000_000) as List<Integer>)
Save it as Stats.groovy and run groovyc Stats.groovy in a terminal. Now try three experiments:
- Introduce an error: change
sumSquarestosumSquareat the call site. With@CompileStatic, compilation fails immediately. Remove the annotation and recompile — it compiles fine and only fails at runtime. - Inspect the bytecode: run
javap -c Stats.classon the compiled output. The statically compiled version calls methods directly; a dynamically compiled version routes through Groovy's CallSite machinery. - Time it: wrap the call in a loop with
System.nanoTime()and compare annotated vs. unannotated runs. Expect a real difference here because this loop is dispatch‑heavy — but don't expect it everywhere (more on that below).
One caveat before you attribute anything: check which Groovy version you're actually running (groovy --version, or your build tool's dependency report). Type inference for closures and generics improved a lot from Groovy 2.x through 4.x, so code that fails static compilation on an old version may compile cleanly on a newer one.
Mixing modes: you don't have to go all‑in
The most practical pattern is annotating the whole class and exempting the methods that genuinely need dynamism:
@CompileStatic
class ReportBuilder {
int total(List<Integer> rows) {
rows.sum() as int // statically compiled hot path
}
@CompileStatic(TypeCheckingMode.SKIP)
def buildDsl(Closure spec) {
// dynamic DSL/metaprogramming code lives here
}
}
TypeCheckingMode.SKIP tells the compiler to leave that one method dynamic. This is how you keep builders and DSL‑heavy code working while compiling everything else.
There's also a middle option: @TypeChecked. It gives you compile‑time type checking but keeps dynamic runtime dispatch, so metaprogramming still works. Choose it when you want safety without the performance‑oriented static dispatch — or when your code depends on runtime method resolution.
The honest trade‑off
Do not expect a universal speedup. The gains are largest in dispatch‑heavy code — tight loops, numeric computation, code called thousands of times per request. For I/O‑bound services or one‑off scripts, the database or network dominates and static compilation buys you almost nothing measurable.
The other cost is migration friction. Code that "worked" dynamically can fail static compilation in surprising ways, especially anything relying on duck typing. Treat adoption as incremental: annotate new code and hot paths first, skip dynamic methods explicitly, and let the compiler's error messages guide you.
Where to start
Pick one computationally heavy class in your codebase, add @CompileStatic, and fix whatever the compiler complains about — those complaints are often latent bugs the dynamic runtime never forced you to confront. Verify the result with javap -c and a simple timing loop rather than trusting anyone's benchmark, including this article's. If the errors pile up faster than you can fix them, that's your signal to use @TypeChecked there instead and move on.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.