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.
Table of Contents
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.
| Feature | Dynamic (default) | @TypeChecked | @CompileStatic |
|---|---|---|---|
| Type errors caught at | Runtime | Compile time | Compile time |
| Method dispatch | MOP (runtime) | MOP (runtime) | Direct (static) |
| Performance | Slower | Same as dynamic | Near Java speed |
| Metaprogramming | Full support | Limited | Not supported |
| DSL support | Full support | With extensions | Very limited |
| Missing method/property | Runtime error | Compile error | Compile 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
@TypeCheckedor@CompileStaticto 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
@CompileStaticclasses and DSL/metaprogramming code in dynamic classes - Use @DelegatesTo: For closure-based APIs, annotate closure parameters with
@DelegatesToto 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:
| Aspect | Dynamic Groovy | @TypeChecked | @CompileStatic |
|---|---|---|---|
| Compilation speed | Fastest | Slightly slower | Slightly slower |
| Runtime speed | Slowest (MOP) | Same as dynamic | Near Java speed |
| Method dispatch | Dynamic (MOP) | Dynamic (MOP) | Static (direct) |
| Boxing/unboxing | Frequent | Frequent | Minimized |
| Memory usage | Higher (MOP data) | Same as dynamic | Lower |
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
@TypeCheckedcatches type errors at compile time but does not change runtime behavior@CompileStaticprovides both compile-time checking and near-Java performance through static method dispatch- Use
TypeCheckingMode.SKIPto exclude methods that need dynamic features from type checking @DelegatesTomakes 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.
Related Posts
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:
- Groovy Command Chain – Natural Language DSLs
- Groovy Operator Overloading – Custom Operators for Your Classes
- Groovy Closures – The Complete Guide
This post is part of the Groovy & Grails Cookbook series on TechnoScripts.com

No comment