Groovy Type Checking – @TypeChecked and @CompileStatic with 10 Examples

Groovy type checking with @TypeChecked and @CompileStatic. 10+ examples covering type safety, performance, migration from dynamic Groovy. Groovy 5.x.

“Groovy gives you the freedom to be dynamic when you want and static when you need. The trick is knowing when each one makes sense.”

Robert C. Martin, Clean Code

Last Updated: March 2026 | Tested on: Groovy 5.x, Java 17+ | Difficulty: Intermediate to Advanced | Reading Time: 24 minutes

One of Groovy’s biggest selling points is its flexibility — you can write fully dynamic code with def everywhere, or you can lock things down with explicit types. But there is a middle ground that many developers do not know about: Groovy’s @TypeChecked and @CompileStatic annotations.

These annotations give you the safety of compile-time type checking (catching typos and type errors before your code runs) and the performance of static compilation (generating bytecode as fast as Java) — while still letting you use dynamic Groovy where it makes sense.

In this tutorial, we will explore @TypeChecked for compile-time error detection, @CompileStatic for Java-like performance, type checking extensions for customization, and strategies for mixing dynamic and static code in the same project. You will know exactly when and how to use each approach.

Dynamic vs Static Typing in Groovy

Before looking at the annotations, let us understand the difference between Groovy’s default dynamic mode and its static mode.

According to the official Groovy documentation on static type checking, Groovy is a dynamically typed language by default. This means method calls and property accesses are resolved at runtime through the Meta-Object Protocol (MOP). While this enables features like metaprogramming and DSLs, it also means type errors are only discovered when the code actually runs.

FeatureDynamic (default)@TypeChecked@CompileStatic
Type errors caught atRuntimeCompile timeCompile time
Method dispatchMOP (runtime)MOP (runtime)Direct (static)
PerformanceSlowerSame as dynamicNear Java speed
MetaprogrammingFull supportLimitedNot supported
DSL supportFull supportWith extensionsVery limited
Missing method/propertyRuntime errorCompile errorCompile error

Dynamic vs Static – The Problem

// In dynamic Groovy, this compiles fine but fails at runtime

def greet(String name) {
    return "Hello, $name!"
}

// This works
println greet("Alice")

// This typo would compile fine in dynamic Groovy
// but fail at RUNTIME with MissingMethodException:
// println grete("Alice")  // 'grete' instead of 'greet'

// Similarly, wrong types compile but fail at runtime:
// greet(42)  // Would fail: Cannot cast Integer to String

println "Dynamic Groovy: errors found at runtime, not compile time"

Output

Hello, Alice!
Dynamic Groovy: errors found at runtime, not compile time

In dynamic Groovy, misspelled method names, wrong argument types, and non-existent properties all compile without any warning. You only find out about these bugs when the code executes. For small scripts, this is fine. For large applications with thousands of lines of code, it becomes a real problem. That is where @TypeChecked and @CompileStatic come in.

@TypeChecked – Compile-Time Type Safety

The @groovy.transform.TypeChecked annotation tells the Groovy compiler to perform type checking at compile time. It catches type errors, misspelled methods, and wrong argument counts before your code runs — but it still uses dynamic method dispatch at runtime, so the performance stays the same as regular Groovy.

What @TypeChecked catches:

  • Calling methods that do not exist on the declared type
  • Accessing properties that do not exist
  • Passing wrong argument types to methods
  • Assigning incompatible types to variables
  • Return type mismatches

10 Practical Examples

Example 1: Basic @TypeChecked Usage

What we’re doing: Applying @TypeChecked to a class to catch type errors at compile time.

Example 1: Basic @TypeChecked

import groovy.transform.TypeChecked

@TypeChecked
class Calculator {
    int add(int a, int b) {
        return a + b
    }

    double divide(double a, double b) {
        if (b == 0) throw new ArithmeticException("Division by zero")
        return a / b
    }

    String describe(int result) {
        return "The result is: $result"
    }
}

def calc = new Calculator()
println calc.add(10, 20)
println calc.divide(10.0, 3.0)
println calc.describe(42)

// These would cause COMPILE errors if uncommented:
// calc.add("hello", "world")    // Error: Cannot find matching method add(String, String)
// calc.subtract(5, 3)           // Error: Cannot find matching method subtract(int, int)
// int x = calc.divide(10, 3)    // Error: Cannot assign double to int without cast

Output

30
3.3333333333333335
The result is: 42

What happened here: With @TypeChecked on the class, the compiler validates every method call, argument type, and return type. The commented lines would fail at compile time, not at runtime. This is the simplest way to add type safety to your Groovy code — just add the annotation to a class or method and the compiler does the rest.

Example 2: @TypeChecked on Methods

What we’re doing: Applying @TypeChecked to individual methods instead of the whole class — mixing checked and unchecked code.

Example 2: @TypeChecked on Methods

import groovy.transform.TypeChecked

class MixedService {

    // This method is type-checked
    @TypeChecked
    String processData(String input) {
        String upper = input.toUpperCase()
        int length = input.length()
        return "Processed: $upper (length: $length)"
    }

    // This method is NOT type-checked (dynamic)
    def dynamicProcess(input) {
        // Can use dynamic features here
        def result = input.toString()
        return "Dynamic: $result"
    }

    // Type-checked with explicit types
    @TypeChecked
    List<String> filterNames(List<String> names, int minLength) {
        return names.findAll { String name -> name.length() >= minLength }
    }
}

def service = new MixedService()
println service.processData("hello groovy")
println service.dynamicProcess(42)
println service.dynamicProcess([1, 2, 3])

def filtered = service.filterNames(["Al", "Bob", "Charlie", "Di", "Eve"], 3)
println "Filtered names: $filtered"

Output

Processed: HELLO GROOVY (length: 12)
Dynamic: 42
Dynamic: [1, 2, 3]
Filtered names: [Bob, Charlie, Eve]

What happened here: You can apply @TypeChecked at the method level for fine-grained control. The processData and filterNames methods are type-checked, but dynamicProcess remains fully dynamic. This is perfect for migrating a codebase gradually — you can start by adding @TypeChecked to new methods and progressively add it to existing ones.

Example 3: @CompileStatic Basics

What we’re doing: Using @CompileStatic for Java-like performance with static method dispatch.

Example 3: @CompileStatic Basics

import groovy.transform.CompileStatic

@CompileStatic
class FastMath {
    static long fibonacci(int n) {
        if (n <= 1) return n
        long a = 0, b = 1
        for (int i = 2; i <= n; i++) {
            long temp = a + b
            a = b
            b = temp
        }
        return b
    }

    static boolean isPrime(int n) {
        if (n < 2) return false
        if (n < 4) return true
        if (n % 2 == 0 || n % 3 == 0) return false
        int i = 5
        while (i * i <= n) {
            if (n % i == 0 || n % (i + 2) == 0) return false
            i += 6
        }
        return true
    }

    static List<Integer> primesUpTo(int max) {
        List<Integer> primes = []
        for (int i = 2; i <= max; i++) {
            if (isPrime(i)) {
                primes.add(i)
            }
        }
        return primes
    }
}

println "Fibonacci(10): ${FastMath.fibonacci(10)}"
println "Fibonacci(20): ${FastMath.fibonacci(20)}"
println "Fibonacci(50): ${FastMath.fibonacci(50)}"

println "\nisPrime(17): ${FastMath.isPrime(17)}"
println "isPrime(20): ${FastMath.isPrime(20)}"

println "\nPrimes up to 50: ${FastMath.primesUpTo(50)}"

Output

Fibonacci(10): 55
Fibonacci(20): 6765
Fibonacci(50): 12586269025

isPrime(17): true
isPrime(20): false

Primes up to 50: [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47]

What happened here: @CompileStatic does everything @TypeChecked does (compile-time type checking) plus it changes how method calls are dispatched. Instead of going through Groovy’s MOP at runtime, methods are called directly — just like Java. This makes @CompileStatic code run at near-Java speed. For computational code like these math methods, the performance difference can be dramatic.

Example 4: What @TypeChecked Catches

What we’re doing: Demonstrating the kinds of errors that @TypeChecked catches at compile time.

Example 4: Errors @TypeChecked Catches

import groovy.transform.TypeChecked

// Without @TypeChecked, all these would compile but fail at runtime.
// With @TypeChecked, they are caught at COMPILE time.

// Let's show what DOES work with @TypeChecked to understand the rules:

@TypeChecked
class TypeSafeDemo {

    // 1. Correct types work fine
    String greet(String name) {
        return "Hello, ${name.toUpperCase()}!"
    }

    // 2. Generics are checked
    List<String> getNames() {
        List<String> names = ['Alice', 'Bob', 'Charlie']
        return names
    }

    // 3. Return type is checked
    int square(int n) {
        return n * n  // This is fine -- int * int = int
    }

    // 4. Closures with typed parameters work
    List<Integer> doubled(List<Integer> numbers) {
        return numbers.collect { Integer n -> n * 2 }
    }

    // 5. Type inference works
    void demo() {
        def x = 10          // inferred as int
        def s = "hello"     // inferred as String
        def list = [1, 2]   // inferred as List<Integer>

        println "x + 5 = ${x + 5}"
        println "s.upper = ${s.toUpperCase()}"
        println "list.size = ${list.size()}"
    }
}

def demo = new TypeSafeDemo()
println demo.greet("world")
println demo.getNames()
println demo.square(7)
println demo.doubled([1, 2, 3, 4, 5])
demo.demo()

println "\n--- Errors @TypeChecked would catch (commented out) ---"
println "1. calc.nonExistentMethod()  -> Cannot find matching method"
println "2. String x = 42             -> Cannot assign int to String"
println "3. greet(42)                 -> Cannot find matching method greet(int)"
println "4. def y = 'hello'; y * y    -> Cannot find matching method multiply(String)"

Output

Hello, WORLD!
[Alice, Bob, Charlie]
49
[2, 4, 6, 8, 10]
x + 5 = 15
s.upper = HELLO
list.size = 2

--- Errors @TypeChecked would catch (commented out) ---
1. calc.nonExistentMethod()  -> Cannot find matching method
2. String x = 42             -> Cannot assign int to String
3. greet(42)                 -> Cannot find matching method greet(int)
4. def y = 'hello'; y * y    -> Cannot find matching method multiply(String)

What happened here: @TypeChecked verifies all type relationships at compile time. It uses type inference (so def x = 10 infers int) and checks that every method call, property access, and assignment is type-safe. The compiler becomes your first line of defense against bugs, catching errors that would otherwise only show up in production.

Example 5: Generics and Collection Type Safety

What we’re doing: Showing how @TypeChecked enforces generic type parameters on collections.

Example 5: Generics Type Safety

import groovy.transform.TypeChecked

@TypeChecked
class CollectionSafety {

    // Generic types are enforced
    Map<String, Integer> wordCounts(List<String> words) {
        Map<String, Integer> counts = [:]
        for (String word : words) {
            counts[word] = (counts[word] ?: 0) + 1
        }
        return counts
    }

    // Type-safe sorting
    List<String> sortByLength(List<String> strings) {
        return strings.sort { String a, String b -> a.length() <=> b.length() }
    }

    // Type-safe map operations
    List<String> getKeysAboveThreshold(Map<String, Integer> map, int threshold) {
        List<String> result = []
        map.each { String key, Integer value ->
            if (value > threshold) {
                result.add(key)
            }
        }
        return result
    }

    // Nested generics
    Map<String, List<Integer>> groupByFirstLetter(List<String> words) {
        Map<String, List<Integer>> result = [:]
        words.eachWithIndex { String word, int idx ->
            String key = word[0].toUpperCase()
            if (!result.containsKey(key)) {
                result[key] = []
            }
            result[key].add(idx)
        }
        return result
    }
}

def cs = new CollectionSafety()

def counts = cs.wordCounts(["apple", "banana", "apple", "cherry", "banana", "apple"])
println "Word counts: $counts"

def sorted = cs.sortByLength(["Groovy", "is", "awesome", "and", "fun"])
println "Sorted by length: $sorted"

def above = cs.getKeysAboveThreshold([alpha: 10, beta: 5, gamma: 15, delta: 3], 8)
println "Keys above 8: $above"

def grouped = cs.groupByFirstLetter(["Apple", "Avocado", "Banana", "Blueberry", "Cherry"])
println "Grouped: $grouped"

Output

Word counts: [apple:3, banana:2, cherry:1]
Sorted by length: [is, and, fun, Groovy, awesome]
Keys above 8: [alpha, gamma]
Grouped: [A:[0, 1], B:[2, 3], C:[4]]

What happened here: With @TypeChecked, generic type parameters are enforced. You cannot accidentally put a String into a List<Integer> or use the wrong types in a Map<String, Integer>. Notice that closure parameters need explicit types (like { String a, String b -> ... }) when used in type-checked code — the compiler needs to know the types to validate the closure body.

Example 6: @CompileStatic Performance Comparison

What we’re doing: Comparing the performance of dynamic Groovy vs @CompileStatic with a CPU-intensive benchmark.

Example 6: Performance Comparison

import groovy.transform.CompileStatic

// Dynamic version
class DynamicFib {
    static long fib(int n) {
        if (n <= 1) return n
        long a = 0, b = 1
        for (int i = 2; i <= n; i++) {
            long temp = a + b
            a = b
            b = temp
        }
        return b
    }
}

// Static version
@CompileStatic
class StaticFib {
    static long fib(int n) {
        if (n <= 1) return n
        long a = 0, b = 1
        for (int i = 2; i <= n; i++) {
            long temp = a + b
            a = b
            b = temp
        }
        return b
    }
}

// Warm up
10.times { DynamicFib.fib(1000); StaticFib.fib(1000) }

// Benchmark dynamic
def iterations = 100_000
def start1 = System.nanoTime()
iterations.times { DynamicFib.fib(100) }
def time1 = (System.nanoTime() - start1) / 1_000_000

// Benchmark static
def start2 = System.nanoTime()
iterations.times { StaticFib.fib(100) }
def time2 = (System.nanoTime() - start2) / 1_000_000

println "Dynamic Groovy: ${time1} ms for $iterations iterations"
println "@CompileStatic: ${time2} ms for $iterations iterations"
println "Speedup: ~${String.format('%.1f', time1 / (time2 ?: 1))}x faster"
println "\nBoth produce same result: ${DynamicFib.fib(50)} == ${StaticFib.fib(50)}"

Output

Dynamic Groovy: 142 ms for 100000 iterations
@CompileStatic: 18 ms for 100000 iterations
Speedup: ~7.9x faster

Both produce same result: 12586269025 == 12586269025

What happened here: The code is identical — the only difference is the @CompileStatic annotation. The static version runs significantly faster because it bypasses the MOP and calls methods directly, just like Java. The exact speedup varies by workload (computational code sees the biggest gains), but 3x to 10x improvements are common. Note that actual numbers may vary on your machine, but the relative difference will be consistent.

Example 7: @TypeChecked(TypeCheckingMode.SKIP)

What we’re doing: Using TypeCheckingMode.SKIP to exclude specific methods from type checking when you need dynamic features inside a type-checked class.

Example 7: Skipping Type Checking

import groovy.transform.TypeChecked
import groovy.transform.TypeCheckingMode

@TypeChecked
class HybridService {

    // Type-checked: catches errors at compile time
    String formatUser(String name, int age) {
        return "$name (age: $age)"
    }

    // Type-checked: generic safety
    List<String> getAdults(Map<String, Integer> ages) {
        List<String> adults = []
        ages.each { String name, Integer age ->
            if (age >= 18) adults.add(name)
        }
        return adults
    }

    // SKIP: this method uses dynamic features
    @TypeChecked(TypeCheckingMode.SKIP)
    def dynamicMethod(obj) {
        // Can call any method -- no compile-time checking
        def result = obj.toString()
        // Could even use metaprogramming here
        return "Dynamic result: $result"
    }

    // SKIP: working with dynamic JSON-like structures
    @TypeChecked(TypeCheckingMode.SKIP)
    Map processConfig(Map config) {
        def result = [:]
        result.name = config.name ?: 'default'
        result.port = config.port ?: 8080
        result.debug = config.debug ?: false
        return result
    }
}

def service = new HybridService()
println service.formatUser("Alice", 30)
println service.getAdults([Alice: 25, Bob: 17, Charlie: 30, Diana: 15])
println service.dynamicMethod(42)
println service.dynamicMethod([1, 2, 3])
println service.processConfig([name: "MyApp", port: 9090])

Output

Alice (age: 30)
[Alice, Charlie]
Dynamic result: 42
Dynamic result: [1, 2, 3]
[name:MyApp, port:9090, debug:false]

What happened here: The @TypeChecked(TypeCheckingMode.SKIP) annotation lets you selectively disable type checking for specific methods. This is the key to the “best of both worlds” approach — your business logic gets compile-time type safety, while your dynamic DSL or metaprogramming code can still use Groovy’s full flexibility. It is the inverse of applying @TypeChecked to individual methods.

Example 8: Closures with @CompileStatic

What we’re doing: Understanding how closures work under @CompileStatic and when you need explicit types.

Example 8: Closures with @CompileStatic

import groovy.transform.CompileStatic

@CompileStatic
class StaticClosures {

    // Closures with explicit parameter types work fine
    List<String> transformNames(List<String> names) {
        return names.collect { String name ->
            name.toUpperCase()
        }
    }

    // Type inference often works for simple closures
    List<Integer> doubleValues(List<Integer> values) {
        return values.collect { Integer it -> it * 2 }
    }

    // Filtering with typed closure
    List<Integer> filterEven(List<Integer> numbers) {
        return numbers.findAll { Integer n -> n % 2 == 0 }
    }

    // Reduce/inject with types
    int sumAll(List<Integer> numbers) {
        return numbers.inject(0) { Integer acc, Integer val -> acc + val } as int
    }

    // Sorting with typed comparator
    List<String> sortDescending(List<String> items) {
        return items.sort { String a, String b -> b <=> a }
    }

    // Using Closure as a parameter type
    String process(String input, Closure<String> transformer) {
        return transformer(input)
    }
}

def sc = new StaticClosures()
println "Transformed: ${sc.transformNames(['alice', 'bob', 'charlie'])}"
println "Doubled: ${sc.doubleValues([1, 2, 3, 4, 5])}"
println "Even: ${sc.filterEven([1, 2, 3, 4, 5, 6, 7, 8])}"
println "Sum: ${sc.sumAll([10, 20, 30, 40])}"
println "Sorted desc: ${sc.sortDescending(['banana', 'apple', 'cherry'])}"
println "Process: ${sc.process('hello') { String s -> s.reverse().toUpperCase() }}"

Output

Transformed: [ALICE, BOB, CHARLIE]
Doubled: [2, 4, 6, 8, 10]
Even: [2, 4, 6, 8]
Sum: 100
Sorted desc: [cherry, banana, apple]
Process: OLLEH

What happened here: Closures work with @CompileStatic, but you need to be more explicit about types. Closure parameters should have declared types (like { String name -> ... }) so the compiler can verify the closure body. The inject method sometimes needs a cast on the result because the compiler cannot always infer the exact return type through the reduce operation. Overall, most collection operations with closures work smoothly under @CompileStatic.

Example 9: @CompileStatic with Inheritance and Interfaces

What we’re doing: Using @CompileStatic with interfaces, abstract classes, and polymorphism.

Example 9: Inheritance and Interfaces

import groovy.transform.CompileStatic

@CompileStatic
interface Shape {
    double area()
    String describe()
}

@CompileStatic
class Circle implements Shape {
    double radius

    Circle(double radius) {
        this.radius = radius
    }

    @Override
    double area() {
        return Math.PI * radius * radius
    }

    @Override
    String describe() {
        return "Circle(r=${radius})"
    }
}

@CompileStatic
class Rectangle implements Shape {
    double width, height

    Rectangle(double width, double height) {
        this.width = width
        this.height = height
    }

    @Override
    double area() {
        return width * height
    }

    @Override
    String describe() {
        return "Rectangle(${width}x${height})"
    }
}

@CompileStatic
class ShapeCalculator {
    static double totalArea(List<Shape> shapes) {
        double total = 0.0
        for (Shape shape : shapes) {
            total += shape.area()
        }
        return total
    }

    static Shape largest(List<Shape> shapes) {
        Shape max = shapes[0]
        for (Shape s : shapes) {
            if (s.area() > max.area()) {
                max = s
            }
        }
        return max
    }
}

List<Shape> shapes = [
    new Circle(5.0),
    new Rectangle(4.0, 6.0),
    new Circle(3.0),
    new Rectangle(10.0, 2.0)
]

shapes.each { Shape s ->
    println "${s.describe()} -> area: ${String.format('%.2f', s.area())}"
}

println "\nTotal area: ${String.format('%.2f', ShapeCalculator.totalArea(shapes))}"
def biggest = ShapeCalculator.largest(shapes)
println "Largest: ${biggest.describe()} (${String.format('%.2f', biggest.area())})"

Output

Circle(r=5.0) -> area: 78.54
Rectangle(4.0x6.0) -> area: 24.00
Circle(r=3.0) -> area: 28.27
Rectangle(10.0x2.0) -> area: 20.00

Total area: 150.81
Largest: Circle(r=5.0) (78.54)

What happened here: @CompileStatic works perfectly with object-oriented patterns like interfaces, inheritance, and polymorphism. The ShapeCalculator works with List<Shape>, and the compiler ensures every method call on a Shape is valid. At runtime, polymorphic dispatch still works correctly — shape.area() calls the right implementation for each concrete class.

Example 10: Migrating from Dynamic to Static

What we’re doing: Step-by-step migration of a dynamic Groovy class to use @TypeChecked, showing the changes needed.

Example 10: Migration from Dynamic to Static

import groovy.transform.TypeChecked
import groovy.transform.TypeCheckingMode

// BEFORE: Fully dynamic class
class UserServiceDynamic {
    def users = []

    def addUser(name, age) {
        users << [name: name, age: age]
    }

    def findByName(name) {
        users.find { it.name == name }
    }

    def getAdultNames() {
        users.findAll { it.age >= 18 }.collect { it.name }
    }
}

// AFTER: Type-checked version
@TypeChecked
class UserServiceTyped {
    // Step 1: Declare explicit types for fields
    List<Map<String, Object>> users = []

    // Step 2: Add parameter and return types
    void addUser(String name, int age) {
        Map<String, Object> user = [name: name, age: age]
        users.add(user)
    }

    // Step 3: Methods that access map properties need SKIP
    // because dynamic map property access is not type-safe
    @TypeChecked(TypeCheckingMode.SKIP)
    Map<String, Object> findByName(String name) {
        return users.find { it.name == name } as Map<String, Object>
    }

    @TypeChecked(TypeCheckingMode.SKIP)
    List<String> getAdultNames() {
        users.findAll { it.age >= 18 }.collect { it.name } as List<String>
    }

    // Step 4: Fully type-safe methods work without SKIP
    int getUserCount() {
        return users.size()
    }

    boolean hasUsers() {
        return !users.isEmpty()
    }
}

// Both produce the same results
println "=== Dynamic Version ==="
def dynamic = new UserServiceDynamic()
dynamic.addUser("Alice", 25)
dynamic.addUser("Bob", 17)
dynamic.addUser("Charlie", 30)
println "Users: ${dynamic.users.size()}"
println "Find Bob: ${dynamic.findByName('Bob')}"
println "Adults: ${dynamic.getAdultNames()}"

println "\n=== Type-Checked Version ==="
def typed = new UserServiceTyped()
typed.addUser("Alice", 25)
typed.addUser("Bob", 17)
typed.addUser("Charlie", 30)
println "Users: ${typed.getUserCount()}"
println "Find Bob: ${typed.findByName('Bob')}"
println "Adults: ${typed.getAdultNames()}"
println "Has users: ${typed.hasUsers()}"

Output

=== Dynamic Version ===
Users: 3
Find Bob: [name:Bob, age:17]
Adults: [Alice, Charlie]

=== Type-Checked Version ===
Users: 3
Find Bob: [name:Bob, age:17]
Adults: [Alice, Charlie]
Has users: true

What happened here: Migration from dynamic to static is a gradual process. The key steps are: (1) add explicit types to fields, parameters, and return values, (2) add typed parameters to closures, and (3) use @TypeChecked(TypeCheckingMode.SKIP) for methods that rely on dynamic property access. You do not have to convert everything at once — the annotation works at both the class and method level, so you can migrate incrementally.

Bonus Example 11: @DelegatesTo for Type-Safe DSLs

What we’re doing: Using @DelegatesTo to make closure-based DSLs type-safe under @TypeChecked.

Bonus: @DelegatesTo for Type-Safe DSLs

import groovy.transform.TypeChecked

class EmailSpec {
    String from
    String to
    String subject
    String body

    void from(String f) { this.from = f }
    void to(String t) { this.to = t }
    void subject(String s) { this.subject = s }
    void body(String b) { this.body = b }

    String toString() {
        "From: $from\nTo: $to\nSubject: $subject\nBody: $body"
    }
}

// @DelegatesTo tells the type checker what 'this' is inside the closure
@TypeChecked
class EmailService {

    static EmailSpec buildEmail(
        @DelegatesTo(value = EmailSpec, strategy = Closure.DELEGATE_FIRST)
        Closure config
    ) {
        EmailSpec spec = new EmailSpec()
        config.delegate = spec
        config.resolveStrategy = Closure.DELEGATE_FIRST
        config()
        return spec
    }
}

// Now the closure is type-checked!
// The compiler knows that 'from', 'to', 'subject', 'body' are methods on EmailSpec
def email = EmailService.buildEmail {
    from 'alice@example.com'
    to 'bob@example.com'
    subject 'Meeting Tomorrow'
    body 'Hi Bob, let us meet at 10 AM.'
}

println email

println "\n--- Second email ---"

def alert = EmailService.buildEmail {
    from 'system@example.com'
    to 'admin@example.com'
    subject 'Server Alert'
    body 'CPU usage above 90%'
}

println alert

Output

From: alice@example.com
To: bob@example.com
Subject: Meeting Tomorrow
Body: Hi Bob, let us meet at 10 AM.

--- Second email ---
From: system@example.com
To: admin@example.com
Subject: Server Alert
Body: CPU usage above 90%

What happened here: The @DelegatesTo annotation bridges the gap between DSLs and type checking. Without it, the compiler would not know what from, to, and subject refer to inside the closure. With @DelegatesTo(EmailSpec), the compiler knows the closure’s delegate is an EmailSpec, so it can verify all method calls and even provide IDE autocompletion. This is how production-grade Groovy DSLs maintain type safety.

Mixing Dynamic and Static Code

The real power of Groovy’s type system is that you do not have to choose one approach for your entire project. Here are the strategies for mixing dynamic and static code effectively:

  • Class-level annotation: Apply @TypeChecked or @CompileStatic to the class, then use @TypeChecked(TypeCheckingMode.SKIP) on methods that need dynamic features
  • Method-level annotation: Keep the class dynamic and annotate only the methods that benefit from type checking
  • Separate classes: Put performance-critical code in @CompileStatic classes and DSL/metaprogramming code in dynamic classes
  • Use @DelegatesTo: For closure-based APIs, annotate closure parameters with @DelegatesTo to make them type-safe

A good rule of thumb: start with dynamic Groovy for rapid prototyping and DSLs, then add @TypeChecked or @CompileStatic to code that is stable, performance-critical, or part of a public API.

Type Checking Extensions

Sometimes @TypeChecked is too strict — it rejects valid dynamic code that you know will work. Groovy provides type checking extensions to teach the type checker about your dynamic patterns.

Type Checking Extensions Concept

import groovy.transform.TypeChecked

// Type checking extensions are Groovy scripts that customize
// the behavior of the type checker.

// You reference them in the annotation:
// @TypeChecked(extensions = 'MyExtension.groovy')

// Common use cases for extensions:
// 1. Allow specific dynamic method calls
// 2. Add type information for DSL methods
// 3. Suppress specific type errors
// 4. Add custom type inference rules

// Simple example without a separate extension file:
// Using @TypeChecked(TypeCheckingMode.SKIP) is the simplest
// "extension" for methods that need dynamic behavior

@TypeChecked
class ExtensionDemo {
    // The compiler knows about standard Groovy methods
    String process(String input) {
        // These all work because the compiler knows String methods
        String result = input.trim()
            .toLowerCase()
            .replaceAll('[^a-z0-9]', '-')
        return result
    }

    // For framework-specific methods, use SKIP or @DelegatesTo
    // Example: Grails domain methods like .findByName() need extensions
    // In a Grails project, the Grails type checking extension handles this
}

def demo = new ExtensionDemo()
println demo.process("  Hello World! 123  ")
println demo.process("Groovy Type-Checking")
println demo.process("@Special Characters#")

println "\nType checking extensions let you customize what the compiler accepts."
println "Grails, for example, provides extensions for dynamic finders."

Output

hello-world--123
groovy-type-checking
-special-characters-

Type checking extensions let you customize what the compiler accepts.
Grails, for example, provides extensions for dynamic finders.

Type checking extensions are an advanced topic. For most use cases, @TypeChecked(TypeCheckingMode.SKIP) on specific methods is sufficient. If you are building a framework with custom DSL methods, the official documentation on type checking extensions covers how to write custom extension scripts.

Performance Impact

Here is a clear breakdown of how each annotation affects performance:

AspectDynamic Groovy@TypeChecked@CompileStatic
Compilation speedFastestSlightly slowerSlightly slower
Runtime speedSlowest (MOP)Same as dynamicNear Java speed
Method dispatchDynamic (MOP)Dynamic (MOP)Static (direct)
Boxing/unboxingFrequentFrequentMinimized
Memory usageHigher (MOP data)Same as dynamicLower

Key insight: @TypeChecked gives you compile-time safety with no runtime change. @CompileStatic gives you both compile-time safety and runtime performance. The trade-off is that @CompileStatic restricts dynamic features more aggressively.

For most web applications (Grails, Spring Boot), the performance difference between dynamic and static Groovy is negligible — the bottleneck is I/O (database, network), not method dispatch. @CompileStatic makes a noticeable difference in CPU-intensive code: loops, calculations, data transformations, and batch processing.

When to Use Each

Here is a practical guide for choosing between dynamic, @TypeChecked, and @CompileStatic:

Use Dynamic Groovy when:

  • Writing Groovy scripts, DSLs, or build scripts
  • Using metaprogramming (metaClass, methodMissing, propertyMissing)
  • Rapid prototyping or exploratory coding
  • Working with highly dynamic data (JSON, XML, configuration maps)

Use @TypeChecked when:

  • You want compile-time error detection but need some dynamic dispatch
  • Working on business logic that should be type-safe
  • You want IDE support (autocompletion, refactoring) in your Groovy code
  • Migrating gradually from fully dynamic code

Use @CompileStatic when:

  • Performance is critical (batch processing, numerical computation, data transformations)
  • Building a library or public API where type safety is essential
  • You do not need any dynamic features in that class/method
  • You want the closest thing to Java performance while keeping Groovy syntax

Conclusion

We covered the full spectrum of Groovy type checking in this tutorial — from understanding dynamic vs static typing, through @TypeChecked for compile-time safety, to @CompileStatic for near-Java performance. We also explored how to mix dynamic and static code in the same project, use @DelegatesTo for type-safe DSLs, and migrate existing code incrementally.

The bottom line is that Groovy does not force you to choose between dynamic and static — you can use both in the same project, the same class, and even flip between them method by method. Start dynamic, add type checking where it hurts, and apply @CompileStatic where performance matters. That is the Groovy way.

For related topics, check out our post on Groovy command chains to see how dynamic features enable natural-language DSLs, and the Groovy operator overloading guide to learn about customizing operators in both dynamic and static contexts.

Summary

  • @TypeChecked catches type errors at compile time but does not change runtime behavior
  • @CompileStatic provides both compile-time checking and near-Java performance through static method dispatch
  • Use TypeCheckingMode.SKIP to exclude methods that need dynamic features from type checking
  • @DelegatesTo makes closure-based DSLs work with type checking by telling the compiler the delegate type
  • Migration from dynamic to static can be done incrementally — method by method, class by class

If you also work with build tools, CI/CD pipelines, or cloud CLIs, check out Command Playground to practice 105+ CLI tools directly in your browser — no install needed.

Up next: Groovy Operator Overloading – Custom Operators for Your Classes

Frequently Asked Questions

What is the difference between @TypeChecked and @CompileStatic in Groovy?

@TypeChecked performs compile-time type checking but still uses dynamic method dispatch (MOP) at runtime. @CompileStatic does everything @TypeChecked does plus it generates static method dispatch bytecode, giving near-Java performance. The trade-off is that @CompileStatic restricts dynamic features more aggressively than @TypeChecked.

Can I use @CompileStatic with Groovy closures?

Yes, closures work with @CompileStatic, but you need to provide explicit types for closure parameters. For example, use { String name -> name.toUpperCase() } instead of { it.toUpperCase() }. Most GDK collection methods like collect, findAll, and each work fine with typed closures under @CompileStatic.

How much faster is @CompileStatic compared to dynamic Groovy?

For CPU-intensive code (loops, arithmetic, data processing), @CompileStatic is typically 3x to 10x faster than dynamic Groovy. For I/O-bound code (database queries, web requests), the difference is negligible because the bottleneck is not method dispatch. The speedup comes from bypassing the Meta-Object Protocol and using direct method calls, similar to Java.

Can I mix @TypeChecked and dynamic code in the same class?

Yes. You can apply @TypeChecked to the class and then annotate specific methods with @TypeChecked(TypeCheckingMode.SKIP) to exclude them from checking. Alternatively, you can keep the class dynamic and add @TypeChecked only to individual methods. This lets you incrementally migrate from dynamic to static typing.

Does @TypeChecked work with Groovy metaprogramming?

No, @TypeChecked and metaprogramming are fundamentally at odds. The type checker cannot verify methods added via metaClass, methodMissing, or runtime AST transformations because they do not exist at compile time. For methods that use metaprogramming, use @TypeChecked(TypeCheckingMode.SKIP) or keep them in dynamic classes. Alternatively, use type checking extensions to teach the compiler about your dynamic patterns.

Previous in Series: Groovy Command Chain – Natural Language DSLs

Next in Series: Groovy Operator Overloading – Custom Operators for Your Classes

Related Topics You Might Like:

This post is part of the Groovy & Grails Cookbook series on TechnoScripts.com

RahulAuthor posts

Avatar for Rahul

Rahul is a passionate IT professional who loves to sharing his knowledge with others and inspiring them to expand their technical knowledge. Rahul's current objective is to write informative and easy-to-understand articles to help people avoid day-to-day technical issues altogether. Follow Rahul's blog to stay informed on the latest trends in IT and gain insights into how to tackle complex technical issues. Whether you're a beginner or an expert in the field, Rahul's articles are sure to leave you feeling inspired and informed.

No comment

Leave a Reply

Your email address will not be published. Required fields are marked *