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.

flowchart TD src["call site in source: s.length()"] --> mode{"annotation on the enclosing class or method?"} mode -- "none (default)" --> dyn["dynamic Groovy"] mode -- "@TypeChecked" --> chk["type-checked Groovy"] mode -- "@CompileStatic" --> sta["statically compiled Groovy"] dyn --> d1["no compile-time check of the receiver"] d1 --> d2["bytecode: invokedynamic call site"] d2 --> d3["method chosen at run time from the real argument types, MetaClass, categories and methodMissing apply"] chk --> c1["compiler infers types and rejects calls that cannot match: compile error"] c1 --> c2["bytecode: still an invokedynamic call site"] c2 --> c3["method chosen at run time, so metaprogramming still runs, but only for calls that passed the check"] sta --> s1["compiler infers types and rejects calls that cannot match: compile error"] s1 --> s2["bytecode: direct invokevirtual or invokestatic"] s2 --> s3["method chosen at compile time from the static types, no MetaClass lookup, JIT can inline"]

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 def gives Object, 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 @CompileDynamic on one method is the escape hatch for the one place that needs methodMissing, an ExpandoMetaClass or 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 --configscript does 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 @CompileDynamic or an extension

Cannot assign value of type A to variable of type B

Incompatible assignment

Convert explicitly (as, a cast, a constructor)

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 instanceof or a cast, or give the variable a type

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.