Static Type Checking & @CompileStatic
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Apache Groovy 6.0 (6.0.0, 2026-09-22) and every example was run with Groovy 6.0.0 on JDK 26
(checked on 2026-10-14). Groovy is dynamically typed by default and lets you opt in to types where you want them. Two
annotations, groovy.transform.TypeChecked and groovy.transform.CompileStatic, go further and ask the compiler to
verify types the way javac does: @TypeChecked only checks, @CompileStatic checks and then also generates direct,
Java-like bytecode. Both arrived in Groovy 2.0 (2012). The keywords def, var and typed declarations are on
Variables, Typing, def, var & val; writing your own
checks with extension scripts is on Type Checking Extensions
and the checkers shipped with Groovy are on Built-in Type Checkers.
The listings use the greenhouse scenario of this section. Compiler messages are reproduced from the real runs, with only the long scratch-directory path in front of the file name removed.
Three ways to compile a call
The same source line can reach the JVM in three different ways, and nearly everything on this page follows from which one is in force.
The three listings below were compiled with groovyc and their bytecode disassembled with javap -c. Only the method bodies
differ: the first has an untyped parameter, the second is @TypeChecked, the third @CompileStatic.
import groovy.transform.CompileStatic
import groovy.transform.TypeChecked
class Calls {
int dynamicLen(s) { s.length() }
@TypeChecked
int checkedLen(String s) { s.length() }
@CompileStatic
int staticLen(String s) { s.length() }
}
== dynamicLen
1: invokedynamic #43, 0 // InvokeDynamic #0:invoke:(Ljava/lang/Object;)Ljava/lang/Object;
6: invokestatic #48 // Method org/codehaus/groovy/runtime/typehandling/DefaultTypeTransformation.intUnbox:(Ljava/lang/Object;)I
== checkedLen
1: invokedynamic #55, 0 // InvokeDynamic #0:invoke:(Ljava/lang/String;)Ljava/lang/Object;
6: invokestatic #48 // Method org/codehaus/groovy/runtime/typehandling/DefaultTypeTransformation.intUnbox:(Ljava/lang/Object;)I
== staticLen
1: invokevirtual #62 // Method java/lang/String.length:()I
@TypeChecked changes what the compiler accepts, not what it generates: the call is still dispatched at run time. Only
@CompileStatic emits the invokevirtual that javac would. The performance and semantic differences in the following
sections are the consequences of that.
Optional typing
Types are optional everywhere in Groovy. An untyped parameter or def variable is an Object to the compiler and whatever
it really is at run time to the dispatcher, so a call on it is looked up when the line executes. A typed declaration is
checked on assignment (a value that cannot be coerced to it fails at run time) but, in plain dynamic code, calls through it
are still resolved dynamically.
def reading(sensor) { sensor.value * 2 } // untyped: anything with a value property
double typed(Map sensor) { sensor.value * 2 } // typed parameter and return
class Probe { double value = 21.5 }
println reading([value: 3])
println reading(new Probe())
println typed([value: 4.5])
try { reading('basil') } catch (MissingPropertyException e) { println e.message }
6
43.0
9.0
No such property: value for class: java.lang.String
reading accepts a map and a Probe alike (duck typing) and fails only when handed something without a value, and only
when that call runs. That is the trade-off the two annotations let you move: they bring the failure to compile time, at the
price of some of the flexibility.
@TypeChecked
@TypeChecked can be put on a class (every method, constructor, property initialiser and closure inside it), or on a single
method or constructor. Inside that scope the compiler infers the type of every expression and refuses to compile what cannot
be valid.
import groovy.transform.TypeChecked
@TypeChecked
class Greenhouse {
String name = 'north'
int zones = 3
String describe() { name.toUpperCase() + ': ' + zones }
}
println new Greenhouse().describe()
NORTH: 3
Well-typed code behaves as before. The point is what is rejected. Four typical mistakes in one class, all reported together:
import groovy.transform.TypeChecked
@TypeChecked
class Greenhouse {
String name = 'north'
int zones() { name.size() }
String oops() { name.toUpperCasee() }
int bad() { 'many' }
void assign() { int n = 'text' }
void missing() { println unknownVar }
}
e3.groovy: 7: [Static type checking] - Cannot find matching method java.lang.String#toUpperCasee(). Please check if the declared type is correct and if the method exists.
@ line 7, column 21.
String oops() { name.toUpperCasee() }
^
e3.groovy: 8: [Static type checking] - Cannot return value of type java.lang.String for method returning int
@ line 8, column 17.
int bad() { 'many' }
^
e3.groovy: 9: [Static type checking] - Cannot assign value of type java.lang.String to variable of type int
@ line 9, column 29.
void assign() { int n = 'text' }
^
e3.groovy: 10: [Static type checking] - The variable [unknownVar] is undeclared.
@ line 10, column 30.
void missing() { println unknownVar }
^
4 errors
Without the annotation each of these would compile and fail only when oops() and the others were called. Generics are
checked too: the element type of a List<String> and the key and value types of a Map<String, Integer> are enforced.
import groovy.transform.TypeChecked
@TypeChecked
void generics() {
List<String> names = ['basil']
names << 42
Map<String, Integer> counts = [a: 1]
counts.put('b', 'two')
int n = names.first()
}
k2.groovy: 6: [Static type checking] - Cannot call <T> org.codehaus.groovy.runtime.DefaultGroovyMethods#leftShift(java.util.List<T>, T) with arguments [java.util.ArrayList<java.lang.String>, int]
@ line 6, column 5.
names << 42
^
k2.groovy: 8: [Static type checking] - Cannot call java.util.LinkedHashMap#put(java.lang.String, java.lang.Integer) with arguments [java.lang.String, java.lang.String]
@ line 8, column 5.
counts.put('b', 'two')
^
k2.groovy: 9: [Static type checking] - Cannot assign value of type java.lang.String to variable of type int
@ line 9, column 13.
int n = names.first()
^
3 errors
Checking is not limited to your own classes: GDK methods such as leftShift above are resolved and checked like any other.
Many dynamic idioms still pass, because @TypeChecked understands them: string concatenation with a non-string
(1 + 'x' gives 1x, and a String plus a null Integer gives anull).
Flow typing
A variable declared with def (or var) does not have one fixed type in checked code: the compiler follows assignments and
instanceof tests and gives the variable the type it holds at each point, which is called flow typing.
import groovy.transform.TypeChecked
@TypeChecked
void flow(Object o) {
if (o instanceof String) {
println o.toUpperCase() // o is a String in this branch
} else if (o instanceof Integer) {
println o + 1 // o is an Integer in this branch
}
def x = 'text'
println x.length() // x holds a String
x = 42
println x.intValue() // x now holds an Integer
x = 'again'
println x.toUpperCase() // and a String again
}
flow('abc')
flow(4)
ABC
4
42
AGAIN
5
4
42
AGAIN
No cast is needed after instanceof, unlike in Java before pattern matching. What the compiler remembers is the type at the
point of use, so using the old type after a reassignment is an error:
import groovy.transform.TypeChecked
@TypeChecked
void bad(Object o) {
def x = 'text'
x = 42
println x.length()
}
f2.groovy: 7: [Static type checking] - Cannot find matching method java.lang.Integer#length(). Please check if the declared type is correct and if the method exists.
@ line 7, column 13.
println x.length()
^
A variable declared with an explicit type does not flow: it keeps its declared type and every assignment is checked against
it, as int n = 'text' was in the earlier listing.
Least upper bound
When the type of an expression depends on a branch (cond ? a : b, or a list literal of mixed elements) the compiler computes
the least upper bound: the most specific type both sides share. The result can be a composite of interfaces, which is why
error messages sometimes show a type written with &.
import groovy.transform.TypeChecked
@TypeChecked
void lub(boolean flag) {
def v = flag ? 1 : 2.5d // Integer and Double: both are Number
println v.intValue()
def w = flag ? 'a' : new StringBuilder('b') // String and StringBuilder: both are CharSequence
println w.length()
def l = [1, 2.5, 3L] // a List of Number
println l.first().intValue()
}
lub(true)
1
1
1
When the two sides have nothing useful in common the bound is only a set of marker interfaces, and the member you wanted is not available:
import groovy.transform.TypeChecked
@TypeChecked
void lub2(boolean flag) {
def v = flag ? 1 : 'one'
println v.toUpperCase()
}
f4.groovy: 6: [Static type checking] - Cannot find matching method (java.io.Serializable & java.lang.Comparable & java.lang.constant.Constable & java.lang.constant.ConstantDesc)#toUpperCase(). Please check if the declared type is correct and if the method exists.
@ line 6, column 13.
println v.toUpperCase()
^
The listed interfaces come from the JDK in use, so the exact text changes between JDK versions. The cure is to narrow with an
instanceof or a cast, or to give v an explicit type.
A closely related rule applies to variables captured and assigned by a closure: if a closure assigns a different type to a local, the compiler can no longer say what the variable holds after the closure may have run, and it uses the least upper bound of all assignments.
import groovy.transform.TypeChecked
@TypeChecked
void shared() {
def x = 1
def c = { x = 'text' } // x is assigned a String inside the closure
c()
println x.intValue()
}
l1.groovy: 8: [Static type checking] - Cannot find matching method (java.io.Serializable & java.lang.Comparable & java.lang.constant.Constable & java.lang.constant.ConstantDesc)#intValue(). Please check if the declared type is correct and if the method exists.
@ line 8, column 13.
println x.intValue()
^
Closures and type inference
Closures passed to GDK methods are typed from their context. For names.collect { it.toUpperCase() } the compiler knows it
is a String because names is a List<String>, and the same holds for inject, each with a map, findAll and the rest of
the GDK.
import groovy.transform.TypeChecked
@TypeChecked
void closures() {
def names = ['basil', 'mint']
println names.collect { it.toUpperCase() }
println names.inject(0) { acc, s -> acc + s.length() }
def m = [a: 1, b: 2]
m.each { k, v -> println k.toUpperCase() + (v + 1) }
}
closures()
[BASIL, MINT]
9
A2
B3
A typo inside the closure is reported against the closure body, with the inferred type:
f6.groovy: 6: [Static type checking] - Cannot find matching method java.lang.String#toUpperCasee(). Please check if the declared type is correct and if the method exists.
@ line 6, column 29.
println names.collect { it.toUpperCasee() }
^
For the GDK this inference is built in. For your own methods that take a closure, the compiler cannot know what the closure
receives unless you say so, and it or an untyped parameter is just Object. Two annotations carry that information.
@ClosureParams
groovy.transform.stc.ClosureParams declares the parameter types of the closure argument. Its value is a hint class that
computes the types, and options feeds the hint. The ones most used are SimpleType (spell the types out),
FirstParam, SecondParam and ThirdParam (take them from another parameter, with FirstParam.FirstGenericType taking the
first generic argument of the first parameter) and FromString for the general case, plus MapEntryOrKeyValue and
FromAbstractTypeMethods for maps and single-abstract-method types.
import groovy.transform.TypeChecked
import groovy.transform.stc.ClosureParams
import groovy.transform.stc.FirstParam
import groovy.transform.stc.SimpleType
class Probe { String id; double value }
// the closure receives one Probe, the element type of the list
static void eachProbe(List<Probe> probes, @ClosureParams(value = FirstParam.FirstGenericType) Closure<?> body) {
probes.each { body.call(it) }
}
// the closure receives a String and an int
static void labelled(@ClosureParams(value = SimpleType, options = ['java.lang.String', 'int']) Closure<?> body) {
body('zone', 3)
}
@TypeChecked
void demo() {
def probes = [new Probe(id: 'p1', value: 20.5), new Probe(id: 'p2', value: 22.0)]
eachProbe(probes) { println it.id.toUpperCase() + ' ' + (it.value + 1) }
labelled { name, n -> println name.toUpperCase() * n }
}
demo()
P1 21.5
P2 23.0
ZONEZONEZONE
Inside the closures, it.id is a String and it.value a double, and a call that does not exist on those types is a
compile error:
g2.groovy: 11: [Static type checking] - Cannot find matching method java.lang.String#abs(). Please check if the declared type is correct and if the method exists.
@ line 11, column 35.
labelled { name, n -> println name.abs() }
^
The hint is only a contract for tools: it does not change what the method passes to the closure at run time, so keep it in step with the body.
@DelegatesTo
groovy.lang.DelegatesTo tells the checker that a closure body is evaluated against a delegate, which is what makes the
statements of a builder-style DSL resolvable. The delegate is described by the annotation, while the runtime assignment of
delegate stays in your method, as covered on
Closure Delegation: owner, delegate & this.
import groovy.transform.TypeChecked
class Tank { int litres = 0; void fill(int n) { litres += n } }
static Tank configure(@DelegatesTo(Tank) Closure<?> spec) {
def t = new Tank()
spec.delegate = t
spec.resolveStrategy = Closure.DELEGATE_FIRST
spec()
t
}
@TypeChecked
void ok() { println configure { fill 40; fill 2 }.litres }
ok()
42
The checker now resolves fill against Tank and rejects an unknown name:
g4.groovy: 5: [Static type checking] - Cannot find matching method g4#drain(int). Please check if the declared type is correct and if the method exists.
@ line 5, column 26.
void bad() { configure { drain 1 } }
^
@DelegatesTo has further attributes (strategy, genericTypeIndex, type, target) for other resolve strategies, generic
delegates and methods with several closures; DSL design with it is on
Domain Specific Languages. Closure parameter hints and
@DelegatesTo can be combined on the same parameter.
What @TypeChecked still allows
Because the bytecode is dynamic, a type-checked method keeps Groovy’s run-time method selection (multi-methods). The same
overload pair gives different answers under @TypeChecked and @CompileStatic:
import groovy.transform.CompileStatic
import groovy.transform.TypeChecked
String pick(Object o) { 'object' }
String pick(String s) { 'string' }
String dynamicPick(Object o) { pick(o) }
@TypeChecked
String checkedPick(Object o) { pick(o) }
@CompileStatic
String staticPick(Object o) { pick(o) }
Object value = 'x'
println dynamicPick(value)
println checkedPick(value)
println staticPick(value)
string
string
object
Under @TypeChecked the declared type Object only decides whether the call type-checks, and the method taken is still the
one for the real argument. That is the main difference the next section builds on.
@CompileStatic
@CompileStatic includes everything @TypeChecked checks and then compiles the code statically: method calls become direct
bytecode calls (invokevirtual, invokestatic, invokeinterface), field and property access becomes direct field or
getter/setter access, and primitives stay primitives. It is placed on the same targets: classes (which also covers inner and
anonymous classes and closures inside them), methods and constructors.
Performance
Direct calls give the JIT compiler plain Java-style code to inline, and avoid boxing and the run-time method lookup. A simple
loop shows the gap. The listing times a dynamic version (untyped def total, a range loop) against a typed @CompileStatic
one after a warm-up run, summing i % 7 over 50 million values:
import groovy.transform.CompileStatic
class Bench {
static long dynamicSum(n) {
def total = 0L
for (i in 0..<n) { total += i % 7 }
total
}
@CompileStatic
static long staticSum(int n) {
long total = 0L
for (int i = 0; i < n; i++) { total += i % 7 }
total
}
}
def time = { String label, Closure body ->
body() // warm up
long t0 = System.nanoTime()
def r = body()
long ms = (System.nanoTime() - t0).intdiv(1_000_000)
println "${label}: result=${r}"
ms
}
int n = 50_000_000
long d = time('dynamic') { Bench.dynamicSum(n) }
long s = time('static ') { Bench.staticSum(n) }
println "static is faster: ${s < d}"
println "dynamic ${d} ms, static ${s} ms"
dynamic: result=149999997
static : result=149999997
static is faster: true
dynamic 170 ms, static 29 ms
Two runs on the same machine gave 170 ms and 204 ms for the dynamic version and 29 ms and 30 ms for the static one. A
micro-benchmark like this is only an illustration, and the gap is largest in tight numeric code and smallest where time is
spent in I/O or in library calls that are already compiled. Groovy compiles dynamic code to invokedynamic call sites by
default (see Performance, invokedynamic & Native Images),
which narrowed the gap that existed in older versions, but typed, statically compiled code is still the fastest option and
the one to use for hot paths.
Semantics that change
The code looks the same but is chosen differently, so some programs change meaning.
Overloads are chosen from declared types. The last listing above shows it: staticPick(Object) calls pick(Object) even
though the object is a String, exactly as Java does, while dynamic and type-checked Groovy call pick(String). Code that
relied on multi-methods for double dispatch has to be rewritten, for example with polymorphic methods.
Types stay primitive. The long total and int i of the benchmark above are JVM primitives in the compiled method, while
the untyped version works on boxed objects behind the dispatcher.
Inner classes and closures inherit the annotation. The nested class and the closure below are compiled statically just because the outer class is:
import groovy.transform.CompileStatic
@CompileStatic
class Outer {
List<String> upper(List<String> xs) { xs.collect { it.toUpperCase() } }
static class Inner { int twice(int n) { n * 2 } }
}
println new Outer().upper(['a', 'b'])
println new Outer.Inner().twice(4)
println Outer.Inner.getDeclaredMethods().find { it.name == 'twice' }
[A, B]
8
public int Outer$Inner.twice(int)
What stops working
Anything that exists only at run time cannot be seen by the compiler. The common casualties are shown below; each fragment compiles and runs without the annotation and is rejected with it.
methodMissing and propertyMissing:
import groovy.transform.CompileStatic
class Greeter {
def methodMissing(String name, args) { "dynamic ${name}" }
}
def dyn() { new Greeter().hello() }
println dyn()
@CompileStatic
class Strict { String callIt() { new Greeter().hello() } }
h1.groovy: 11: [Static type checking] - Cannot find matching method Greeter#hello(). Please check if the declared type is correct and if the method exists.
@ line 11, column 34.
class Strict { String callIt() { new Greeter().hello() } }
^
Methods added through ExpandoMetaClass, metaClass, categories or extension through runtime metaprogramming:
import groovy.transform.CompileStatic
String.metaClass.shout = { -> delegate.toUpperCase() + '!' }
def dynamicShout() { 'hi'.shout() }
println dynamicShout()
@CompileStatic
String staticShout() { 'hi'.shout() }
h3.groovy: 9: [Static type checking] - Cannot find matching method java.lang.String#shout(). Please check if the declared type is correct and if the method exists.
@ line 9, column 24.
String staticShout() { 'hi'.shout() }
^
The first call, in dynamic code, succeeds (HI!); the compile error is for the second. The runtime-metaprogramming
alternatives are on Runtime Metaprogramming & the MOP
and the compile-time one, extension modules and AST transformations, which static code can use, is on
AST Transformations: Code Generation & Class Design.
Unknown properties:
import groovy.transform.CompileStatic
class Config { Map<String, Object> values = [:] }
@CompileStatic
class Svc {
int total(Config c) {
int sum = 0
c.values.each { k, v -> sum += (v as int) } // as-coercions still work
sum
}
def broken(Config c) { c.size } // Config has no size property
}
h5.groovy: 13: [Static type checking] - No such property: size for class: Config
@ line 13, column 28.
def broken(Config c) { c.size }
^
Duck typing through untyped parameters stops working too: with def len(s) { s.length() } compiled statically, s is an
Object and length() is not found (an example is in the section on mixing modes), so the parameter needs a type or a cast.
Builders and DSLs written against the dynamic model are the usual reason a team keeps part of a code base dynamic; the
support for them under static code is @DelegatesTo above and type checking extensions.
@CompileDynamic and TypeCheckingMode.SKIP
The annotations can be switched off again for a smaller scope. groovy.transform.CompileDynamic (a shortcut for
@CompileStatic(TypeCheckingMode.SKIP)) opts a method or class out of static compilation inside a statically compiled
class. @TypeChecked(TypeCheckingMode.SKIP) (and @CompileStatic(TypeCheckingMode.SKIP)) does the same from the annotation
itself, with the enumeration in groovy.transform.TypeCheckingMode.
import groovy.transform.CompileStatic
import groovy.transform.CompileDynamic
class Greeter { def methodMissing(String name, args) { "dynamic ${name}" } }
@CompileStatic
class Mixed {
String typed(String s) { s.toUpperCase() }
@CompileDynamic
String loose() { new Greeter().hello() }
}
def m = new Mixed()
println m.typed('basil')
println m.loose()
BASIL
dynamic hello
The same with @TypeChecked:
import groovy.transform.TypeChecked
import groovy.transform.TypeCheckingMode
@TypeChecked
class Strict {
String ok(String s) { s.trim() }
@TypeChecked(TypeCheckingMode.SKIP)
def loose(x) { x.anythingGoes() }
}
def s = new Strict()
println s.ok(' basil ')
try { s.loose(42) } catch (MissingMethodException e) { println e.message.readLines().first() }
basil
No signature of method: anythingGoes for class: java.lang.Integer is applicable for argument types: () values: []
The skipped method compiled, and the failure comes at run time, as in any dynamic code.
Mixing modes
Because the annotations apply to a scope, a code base can combine all three modes. Some practical rules:
-
A statically compiled method can call dynamic code and the reverse, since the signatures are ordinary Java signatures. Static callers see only the declared types: a method returning
defgivesObject, so a call chain through it fails the check until you cast or give it a return type. -
Annotating a class applies to everything inside it, and
@CompileDynamicon one method is the escape hatch for the one place that needsmethodMissing, anExpandoMetaClassor an untyped DSL. -
An annotation can sit on a script method, but a script’s top-level statements have no annotatable declaration, which is why a configuration script (next) is the practical way to cover a whole script.
-
Apply a mode to everything compiled, with no annotation, through a compilation customizer. From the command line
groovy --configscriptdoes it with a short script that adds an AST customizer:
import groovy.transform.CompileStatic
withConfig(configuration) {
ast(CompileStatic)
}
With that configuration script, a script that was fine before is now checked as a whole:
def len(s) { s.length() }
println len('basil')
k4.groovy: 1: [Static type checking] - Cannot find matching method java.lang.Object#length(). Please check if the declared type is correct and if the method exists.
@ line 1, column 14.
def len(s) { s.length() }
^
1 error
Without the configuration script the same file prints 5. Compilation customizers are covered on
Compilation Customizers & Secure Scripting.
Build tools can pass the same configuration script to the compiler; see
Build Tools: Gradle, Maven & Ant.
Reading the error messages
Every static check failure uses the same shape, and it is the same for @TypeChecked and @CompileStatic:
<file>: <line>: [Static type checking] - <message>
@ line <line>, column <column>.
<source line>
<caret under the offending expression>
The messages seen on this page, and what to do about each:
| Message | Usual cause | Fix |
|---|---|---|
Cannot find matching method T#name(args). |
Typo, wrong argument types, or a dynamic or metaprogrammed method |
Fix the name or types; for a dynamic method use |
Cannot assign value of type A to variable of type B |
Incompatible assignment |
Convert explicitly ( |
Cannot return value of type A for method returning B |
Return type mismatch |
Change the declared return type or the value |
The variable [x] is undeclared. |
Misspelt name, or a script binding variable |
Declare it, or pass it as a parameter |
No such property: p for class: C |
Dynamic property access |
Declare the property or use a Map |
Cannot call M with arguments [..] |
Wrong argument types for a generic or overloaded method |
Check the generic parameters |
Cannot find matching method (A & B & ..)#name() |
Least upper bound of two unrelated types |
Narrow with |
Compile errors are reported together (4 errors above), so a build shows all of them at once. When the checker is too strict for a legitimate dynamic pattern the better
tool is usually a type checking extension, not a SKIP: the extension teaches the checker the pattern and keeps the rest of
the method checked.
Where the book is outdated
A book written before Groovy 2.0 (2012) describes Groovy as dynamically typed with optional types, and knows neither
@TypeChecked nor @CompileStatic. The following are therefore missing or under-weighted in it: static type checking and
static compilation themselves (2.0), @DelegatesTo and @ClosureParams for closure hints, type checking extensions,
flow typing and least-upper-bound inference, and the shift of the default dispatch to invokedynamic (Groovy 4.0), which changes
the performance comparison between dynamic and static code. In a modern code base the usual advice is to keep scripts, tests
and DSL code dynamic and to put @CompileStatic on libraries, services and hot paths.