Dynamic Groovy, @TypeChecked or @CompileStatic: Choosing Per Package
A decision guide for Groovy codebases: when to keep dynamic dispatch, when @TypeChecked is enough, and how to promote a package to @CompileStatic with a validation path.
19 Sept 2026, 23:34 UTC

The decision: where do you want type errors to surface?
Groovy's default mode resolves method and property calls at runtime. That is what makes methodMissing, ExpandoMetaClass and internal DSLs work, and it is also why a misspelled property name can sit in a codebase for months until the one code path that touches it runs. @TypeChecked and @CompileStatic move that failure to build time, but they do it differently and at different costs.
The useful framing: this is not a project-wide switch. The annotations apply to a class, a method or a closure, and statically compiled code can still call dynamically compiled Groovy classes and vice versa. The real decision is which packages get which treatment.
Constraints that usually decide it
- Runtime metaprogramming. If a class relies on
methodMissing,propertyMissing, categories or metaclass mutation,@CompileStaticon that class stops consulting the hook rather than calling it.@TypeCheckeddoes not. - DSL and builder entry points. Builders and script-like entry points are the usual reason a codebase cannot be fully static.
- Error timing. A long-running batch job that fails at 3 a.m. on a type error is a different cost from a compile error in CI.
- Tooling. IDEs complete and refactor statically compiled code far more reliably; dynamic property access is often invisible to them.
- Hot paths. Static compilation removes dynamic dispatch overhead, but the gain depends on the workload. Measure rather than assume.
Comparing the three options
| Option | Dispatch at runtime | Errors caught | Metaprogramming hooks | Typical fit |
|---|---|---|---|---|
| Default (no annotation) | Dynamic | At execution | Fully consulted | DSLs, builders, scripts, glue code |
@TypeChecked | Dynamic | At compile time | Still consulted | First migration step; code that must stay dynamic but wants earlier errors |
@CompileStatic | Direct calls | At compile time | Not consulted in the annotated scope | Services, domain models, hot paths |
Trade-offs that actually bite
Three surprises come up repeatedly when teams promote code.
Closures inherit the annotation. A closure written inside a statically compiled class is itself statically compiled. If that closure calls a dynamic API — a builder, a metaclass method, a map-style property — compilation fails even though the enclosing class looked fine. The usual fix is to move the dynamic call into an unannotated method and call that.
Dynamic property access stops compiling. obj.someProperty where the receiver is def or an interface without that property is accepted dynamically and rejected statically. Explicit casts or a typed interface resolve most of these.
Generics get louder. Static compilation infers types more aggressively, so raw collections and mixed-type lists that previously worked now need type arguments. This is usually the bulk of a migration diff.
Boundary behaviour is the reassuring part: annotating a service package does not force you to annotate the DSL package that calls it, and the reverse also holds.
A concrete migration path
Work one cohesive package at a time. Start with @TypeChecked, because it surfaces type errors without changing runtime dispatch — so a failure is a genuine type problem, not a lost metaprogramming hook.
// src/main/groovy/example/InvoiceService.groovy
package example
import groovy.transform.TypeChecked
@TypeChecked
class InvoiceService {
BigDecimal total(List<BigDecimal> lines) {
BigDecimal sum = 0G
for (BigDecimal line : lines) {
sum += line
}
return sum
}
}
Fix what it reports. Then promote the same package to @CompileStatic and re-run the tests. Any new failure at this step is a dispatch change rather than a type error, so it points straight at a metaprogramming dependency.
// src/main/groovy/example/ReportDsl.groovy
package example
// Deliberately left dynamic: this class depends on methodMissing.
class ReportDsl {
def methodMissing(String name, args) {
"column:${name} args:${args}"
}
}
Keep those exceptions in one place and comment why. A short list of documented dynamic classes is maintainable; a silent mix is not.
Project-wide application is possible through a compiler configuration script, but the syntax has changed between major Groovy releases. If you go that route, confirm the exact form against the release notes for the version you actually resolve. Mark for review before adopting.
Validating that the annotation is doing anything
- Confirm the resolved Groovy version. Print
GroovySystem.versionfrom a test or a script run against the project's classpath. Build files copied from older projects may reference different artifact coordinates than the version in use — group and module naming changed at Groovy 4 — so verify rather than trust the build file. - Prove the annotation bites. Add a throwaway class with an obvious mismatch and compile it twice, once with the annotation and once without.
// src/test/groovy/example/TypeCheckProbe.groovy
package example
import groovy.transform.TypeChecked
@TypeChecked
class TypeCheckProbe {
int broken() {
String s = 42
return s
}
}
Expected: the annotated build fails with a type error; the unannotated build compiles and only fails if broken() is called. If both builds pass, the annotation is not being applied where you think it is. Delete the probe afterwards.
- Inspect a call site. Disassemble a compiled class with
javap -c -p <path-to-class>. Statically compiled calls appear as ordinaryinvokevirtual/invokeinterfaceinstructions; dynamically compiled calls go through call-site machinery. Class output paths differ by build tool, so substitute your own. Exact bytecode shape varies by Groovy major version. - Run the full test suite after each promotion and diff the failures. Separate genuine type errors from changed dynamic behaviour before deciding whether to fix the code or leave the class dynamic.
Limitations and what to check before committing
- Performance improvement is workload-dependent. Benchmark the real hot path before claiming a win.
- Annotation semantics around closures, generics and property access have been refined across releases. Behaviour confirmed on one major version should not be assumed identical on another.
- Applying
@CompileStaticbroadly to a metaprogramming-heavy codebase can break working code at compile time. Migrate incrementally. - Static compilation is not a correctness guarantee: it catches type errors, not logic errors, and
@TypeCheckedstill dispatches dynamically at runtime.
The practical default for most production Groovy: statically compile service and domain layers, keep DSL and script entry points dynamic, and document each exception.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.