Type Checking Extensions

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). The static type checker described on Static Type Checking & @CompileStatic knows nothing about what a program does through the runtime metaobject protocol: a builder that answers any method name, a script whose variables come from a binding, a class that overrides methodMissing. A type checking extension is a piece of compiler-side code, usually a short Groovy script, that plugs into the checker so that it can understand such code, or that makes the checker reject code it would normally accept. The extension runs while the compiler type checks the code, so mistakes are reported at compile time with a normal error message and a line number. The checkers Groovy ships with are built on the same mechanism and are described on Built-in Type Checkers; this page is about writing your own.

When an extension is the right tool

Without help, type checking and runtime dynamism pull in opposite directions. A markup builder is the standard example: in html { body { p 'Hello' } } none of html, body or p is a method that exists anywhere, and there is no finite list of tag names the checker could learn. For most code that is a reason not to type check the builder. For a domain-specific language that end users type in as scripts, it is a problem: the user may not even realise that what they wrote is code, and a typo found only at run time can be expensive. The Groovy documentation uses a rover on Mars as the example, where a message needs about fifteen minutes to arrive, so a script that fails half-way both delays the feedback and leaves the rover in a state nobody planned for. The goal of an extension is to fail early: reject the script before any of it runs, with an understandable message. The same idea fits a greenhouse controller that accepts scripts from growers.

An extension can do two opposite things:

  • Weaken the checker where it fails: declare that a variable has a type, that a method exists, that an assignment is acceptable.

  • Strengthen the checker where it is silent: forbid certain calls, limit how often something is used, check naming rules.

How DSLs are built (script base classes, @DelegatesTo, builders) is on Domain-Specific Languages and Builders; compilation customizers, used below to switch type checking on for every script, are on Compilation Customizers & Secure Scripting.

Activating an extension

@TypeChecked and @CompileStatic have an extensions attribute holding a list of extension names. A name is either the path of an extension script that is found on the compilation classpath, or the fully qualified name of a precompiled extension class (shown later). The scripts are looked up through the class loader that compiles the code, so the directory holding them must be on that class path. On the command line that is groovy -cp ext script.groovy; when embedding the compiler, put the directory in a class loader that is passed to GroovyShell. In the run for this page, setting CompilerConfiguration.classpath did not make the extension visible: the compiler answered that the extension "was not found on the classpath" until the directory was supplied through a URLClassLoader parent, which is what the driver below does.

A robot with one method, and a script that is just robot.move 100 with robot supplied by the host through a binding. With @TypeChecked applied to the script by a compilation customizer, the checker rejects robot because no declaration is visible. The extension script teaches it that an unresolved variable called robot has type Robot:

// ext/robotextension.groovy
unresolvedVariable { var ->
    if ('robot' == var.name) {
        storeType(var, classNodeFor(Robot))
        handled = true
    }
}
import org.codehaus.groovy.control.CompilerConfiguration
import org.codehaus.groovy.control.customizers.ASTTransformationCustomizer
import groovy.transform.TypeChecked

class Robot {
    int position
    Robot move(int qt) { position += qt; this }
}

void check(String script, Map opts = [:]) {
    def config = new CompilerConfiguration()
    config.addCompilationCustomizers(new ASTTransformationCustomizer(opts, TypeChecked))
    def loader = new URLClassLoader([new File('ext').toURI().toURL()] as URL[], this.class.classLoader)
    def shell = new GroovyShell(loader, new Binding(), config)
    def robot = new Robot()
    shell.setVariable('robot', robot)
    try {
        shell.evaluate(script)
        println "ran: position=${robot.position}"
    } catch (e) {
        println e.message.readLines().findAll { it.contains('[Static type checking]') }*.replaceFirst(/^Script1.groovy: /, '')*.trim().join('\n')
    }
}
println '--- no extension'
check('robot.move 100')
println '--- with extension'
check('robot.move 100', [extensions: ['robotextension.groovy']])
println '--- typo caught before anything runs'
check('robot.move 100\nrobot.mvoe 5', [extensions: ['robotextension.groovy']])
--- no extension
1: [Static type checking] - The variable [robot] is undeclared.
--- with extension
ran: position=100
--- typo caught before anything runs
2: [Static type checking] - Cannot find matching method Robot#mvoe(int). Please check if the declared type is correct and if the method exists.

The first run reproduces the error that motivates extensions. The second one succeeds and the call executes. The third shows the point of the exercise: the second line contains a typo, mvoe, and because type checking happens before execution the first line is not executed either (no ran: line is printed). Source: Groovy documentation: A DSL for type checking

Applying the annotation directly in source works the same way, as long as the extension script is on the class path. With groovy -cp ext and the events.groovy extension listed in the next sections:

import groovy.transform.TypeChecked

@TypeChecked(extensions = 'events.groovy')
class Shopping {
    int words(String list) {
        list.trim().split(' ').length
    }
}
println new Shopping().words(' eggs milk flour ')
type checker saw 2 call(s) on String
3

Adding one more call on String to the method (list.trim().toLowerCase().split(' ').length) makes the compiler refuse the class; the run printed type checker saw 3 call(s) on String followed by this compilation error:

type checker saw 3 call(s) on String
e8.groovy: 6: [Static type checking] - At most 2 calls on String are allowed

Parameterized extensions

An extension name can carry options, written like named arguments in parentheses after the name: 'params.groovy(limit: 1, label: "demo")'. Supported values are booleans, integers, strings in single or double quotes, and null; entries are separated by commas, and string values cannot contain commas or colons because those are the delimiters. Inside a script extension the options are available as the options property, an unmodifiable map; precompiled Java extensions call getOptions(). The same syntax is accepted by ASTTransformationCustomizer. This is the feature the bundled NullChecker(strict: true) and PurityChecker(allows: "LOGGING") use. It is new in Groovy 6.0.

// ext/params.groovy
beforeVisitMethod { m ->
    int limit = options?.limit ?: 3
    if (m.parameters.size() > limit) {
        addStaticTypeError("${options?.label ?: 'extension'}: ${m.name} takes ${m.parameters.size()} parameters, limit is ${limit}", m)
    }
}

The Driver class used by the remaining examples applies @TypeChecked or @CompileStatic with the given extensions to a code string, with ext and pre (precompiled classes) on the class path. It either compiles and runs the code or prints the type checking errors:

import org.codehaus.groovy.control.CompilerConfiguration
import org.codehaus.groovy.control.customizers.ASTTransformationCustomizer
import groovy.transform.TypeChecked
import groovy.transform.CompileStatic

class Driver {
    static Object run(String code, Class ann, List ext, Map vars = [:], String scriptBase = null) {
        compile(code, ann, ext, vars, scriptBase, true)
    }

    static Object compile(String code, Class ann, List ext, Map vars = [:], String scriptBase = null, boolean exec = false) {
        def config = new CompilerConfiguration()
        config.addCompilationCustomizers(new ASTTransformationCustomizer(ann, extensions: ext))
        if (scriptBase) config.scriptBaseClass = scriptBase
        def loader = new URLClassLoader([new File('ext').toURI().toURL(), new File('pre').toURI().toURL()] as URL[], Driver.classLoader)
        def shell = new GroovyShell(loader, new Binding(vars), config)
        try {
            if (exec) return shell.evaluate(code)
            shell.parse(code)
            println 'compiles'
            return null
        } catch (e) {
            println e.message.readLines().findAll { it.contains('[Static type checking]') || it.startsWith('Script1.groovy') && it.contains('extension') }*.replaceFirst(/^Script1.groovy: /, '')*.trim().join('\n')
            return null
        }
    }
}

The parameters extension is exercised in the program under "Scopes" below. Source: Groovy documentation: Parameterized extensions

The event API

The extension script is written in a small DSL. Each construct names an event; the closure runs when the type checker reaches that point. A script may contain many blocks, including several for the same event. Inside a closure, an assignment to handled = true tells the checker that the extension dealt with the situation, so the checker must skip its own handling (and in particular its own error). The events are:

Event Closure arguments When it fires, and typical use

setup

none

After the checker is initialised, before anything else. Initialise counters and state.

finish

none

After type checking is complete. Run checks that need the whole picture.

unresolvedVariable

VariableExpression

A variable has no declaration. Give it a type with storeType (variables injected by a binding or a base script).

unresolvedProperty

PropertyExpression

The checker cannot find a property on the receiver (dynamic properties).

unresolvedAttribute

AttributeExpression

The checker cannot find an attribute (obj.@field) on the receiver.

beforeMethodCall

MethodCall

Before the checker examines a call. With handled = true the standard check for that call is skipped.

afterMethodCall

MethodCall

After the standard checks of a call, even when beforeMethodCall set handled. Add cross-argument checks.

onMethodSelection

Expression, MethodNode

The checker found a target method. May fire for expressions other than method calls, such as binary expressions. Use it to react to a specific call or to enter the scope of a closure-taking method.

methodNotFound

receiver ClassNode, name, ArgumentListExpression, argument types, MethodCall

No target method was found. Return one MethodNode (or a list of them) to supply it; an empty list means not found, and several are reported as ambiguous.

beforeVisitMethod

MethodNode

Before the checker enters a method body. With handled = true the extension takes over the body. Good for limiting the scope of an extension.

afterVisitMethod

MethodNode

After a method body was checked. Report problems found while collecting information.

beforeVisitClass

ClassNode

Before a type checked class, including inner classes of an annotated class, is visited.

afterVisitClass

ClassNode

After a class was checked, including nested and anonymous classes that are not skipped.

incompatibleAssignment

left type, right type, Expression

The checker considers an assignment invalid. Accept it with handled = true when a runtime mechanism such as setProperty converts the value.

incompatibleReturnType

ReturnStatement, value type

The checker considers a return value incompatible with the declared type. Accept it with handled = true.

ambiguousMethods

List<MethodNode>, Expression

Several methods are equally good. Return the one that should win.

The first example uses setup, onMethodSelection, beforeVisitClass and finish. It counts calls on String and rejects a third one, and it refuses class names that start with a lower case letter:

// ext/events.groovy
// counts calls on String and rejects the third one; also checks class names
setup { count = 0 }
onMethodSelection { expr, method ->
    if (method.declaringClass.name == 'java.lang.String') {
        if (++count > 2) {
            addStaticTypeError('At most 2 calls on String are allowed', expr)
        }
    }
}
beforeVisitClass { classNode ->
    if (!(classNode.nameWithoutPackage[0] in 'A'..'Z')) {
        addStaticTypeError("Class '${classNode.nameWithoutPackage}' must start with an uppercase letter", classNode)
    }
}
finish { println "type checker saw ${count} call(s) on String" }
println '--- two String calls: fine'
println Driver.run('''
def s = 'groove'
s.toUpperCase()
s.length()
''', groovy.transform.TypeChecked, ['events.groovy'])
println '--- three String calls: rejected'
Driver.run('''
def s = 'groove'
s.toUpperCase()
s.length()
s.trim()
''', groovy.transform.TypeChecked, ['events.groovy'])
println '--- lower-case class name'
Driver.run('''
class widget { }
''', groovy.transform.TypeChecked, ['events.groovy'])
--- two String calls: fine
type checker saw 2 call(s) on String
6
--- three String calls: rejected
type checker saw 3 call(s) on String
5: [Static type checking] - At most 2 calls on String are allowed
--- lower-case class name
type checker saw 0 call(s) on String
2: [Static type checking] - Class 'widget' must start with an uppercase letter

The finish closure prints its summary even when an error was reported, and the line number in an error is that of the AST node passed to addStaticTypeError (counted from the start of the code string, which begins with a newline).

An extension that handles a call before the checker looks at it, and one that accepts an assignment the checker would reject, use beforeMethodCall and incompatibleAssignment:

// ext/rules2.groovy
// incompatibleAssignment: allow a Closure to be assigned to an int-typed slot (a runtime setProperty would convert it)
incompatibleAssignment { lhsType, rhsType, expr ->
    if (lhsType == classNodeFor(int) && rhsType == classNodeFor(Closure)) {
        handled = true
    }
}
// beforeMethodCall: forbid toUpperCase everywhere
beforeMethodCall { call ->
    if (isMethodCallExpression(call) && call.methodAsString == 'toUpperCase') {
        addStaticTypeError('toUpperCase is not allowed in this DSL', call)
        handled = true
    }
}
import groovy.transform.TypeChecked
println '--- without extension'
Driver.compile('''int n = { 42 }''', TypeChecked, [])
println '--- with extension'
Driver.compile('''int n = { 42 }''', TypeChecked, ['rules2.groovy'])
Driver.compile('''String s = 'a'.toUpperCase()''', TypeChecked, ['rules2.groovy'])
--- without extension
1: [Static type checking] - Cannot assign value of type groovy.lang.Closure<java.lang.Integer> to variable of type int
--- with extension
compiles
1: [Static type checking] - toUpperCase is not allowed in this DSL

Working with the AST

The API is low level: arguments are nodes of the abstract syntax tree (ClassNode, MethodNode, VariableExpression, MethodCallExpression and so on), so you need to know the AST. The script is delegated to org.codehaus.groovy.transform.stc.GroovyTypeCheckingExtensionSupport, which gives it these variables:

  • context: the type checking context, a TypeCheckingContext, holding the stack of enclosing method calls, closures and binary expressions;

  • typeCheckingVisitor: the checker itself, a StaticTypeCheckingVisitor, so an extension can call its methods;

  • generatedMethods: the list of phantom methods created with newMethod.

The script also imports the static members of ClassHelper and StaticTypeCheckingSupport, so constants such as OBJECT_TYPE, STRING_TYPE or THROWABLE_TYPE and helpers such as missesGenericsTypes(ClassNode) are available directly.

Class nodes

Inside an extension, types are ClassNode objects, not class literals: the tree may be incomplete while a class is being checked, so String the class and String the node are different things. classNodeFor(String) returns the resolved node for a class. A variant taking a string name exists but creates an unresolved node without methods or properties, so it should be kept for special cases. When the type is one that is being compiled in the same batch and does not exist as a class yet, use lookupClassNodeFor('Foo').

Telling the checker about types

storeType(node, type) records the type of any expression, not only variables; getType(node) returns the inferred type of an expression. Inferred is the important word: for a variable declared as Object that holds a String at that point, getType returns String because of flow typing. To get the declared type, ask the AST node itself.

Reporting errors

addStaticTypeError(message, node) reports a compilation error. Pass the AST node that is most responsible, since its position gives the line and column in the message. For readability the DSL also provides isBinaryExpression(node) and its relatives, which stand for node instanceof BinaryExpression.

Virtual methods

When the code calls a method that does not exist but is known to work at run time, such as a dynamic finder findByName, the extension can invent a phantom method: newMethod(name, returnType), where the type is a Class, a ClassNode or a closure that computes the ClassNode lazily (useful for forward references). The method is added to generatedMethods, so isGenerated(methodNode) can recognise it. methodNotFound returns such a method, and unresolvedProperty stores a type on the property expression. The extension below teaches the checker a French vocabulary that a runtime metaclass would provide: longueur() on a String returns an int, and the property majuscules is a String.

// ext/french.groovy
// teach the checker about a "French" vocabulary that a runtime MOP provides
methodNotFound { receiver, name, argList, argTypes, call ->
    if (receiver == classNodeFor(String) && name == 'longueur' && argList.size() == 0) {
        handled = true
        return newMethod('longueur', classNodeFor(int))
    }
}
unresolvedProperty { pexp ->
    if (pexp.propertyAsString == 'majuscules' && getType(pexp.objectExpression) == STRING_TYPE) {
        storeType(pexp, STRING_TYPE)
        handled = true
    }
}

Scopes

Extensions must know exactly when they apply. In a builder (builder.foo { bar; baz(bar) }), rules should be active inside foo only, and nested or repeated builders complicate it further. A scope stack handles this. newScope() pushes a scope, a map of custom data with a parent; currentScope reads it; scopeExit { ... } registers a closure that runs when the scope is popped, and is the place to run deferred checks such as forward references (a variable used before its declaration is resolved later). newScope { count = 0 } combines creating a scope and initialising its data. The general scheme is: push a scope at a pointcut such as entering a method that takes a closure, collect information with the other events, and check in scopeExit.

// ext/scoped.groovy
// a scope per method: count method calls and complain at the end of the method
beforeVisitMethod { methodNode ->
    newScope { calls = 0 }
}
onMethodSelection { expr, method ->
    if (currentScope?.calls != null) currentScope.calls++
}
afterVisitMethod { methodNode ->
    scopeExit {
        if (calls > 2) {
            addStaticTypeError("Method ${methodNode.name} contains more than 2 method calls (found ${calls})", methodNode)
        }
    }
}

The helper methods worth knowing beyond these: isDynamic(variableExpression) (a script variable declared without type or def), isGenerated(methodNode), isAnnotatedBy(node, SomeAnnotation), getTargetMethod(call) (the method the checker selected) and delegatesTo(...), which emulates @DelegatesTo for an argument. The complete list is in the Javadoc of GroovyTypeCheckingExtensionSupport and TypeCheckingExtension.

Here is a program that runs the French, scoped and parameters extensions. Driver.compile only compiles the code, it does not execute it (the French methods do not exist at run time, so running the code would fail):

import groovy.transform.TypeChecked
println '--- unknown method and property, taught by the extension'
Driver.compile('''
class Greeting {
    int size(String s) { s.longueur() }
    String shout(String s) { s.majuscules }
}
new Greeting().size('bonjour')
''', TypeChecked, ['french.groovy'])
println '--- same code without the extension'
Driver.compile('''
class Greeting {
    int size(String s) { s.longueur() }
}
''', TypeChecked, [])
println '--- scopes'
Driver.compile('''
class Chain {
    String a(String s) { s.trim().toUpperCase().reverse() }
    String b(String s) { s.trim() }
}
''', TypeChecked, ['scoped.groovy'])
println '--- parameters'
Driver.compile('''
class Wide { void f(int a, int b) { } }
''', TypeChecked, ['params.groovy(limit: 1, label: "demo")'])
Driver.compile('''
class Wide { void f(int a, int b) { } }
''', TypeChecked, ['params.groovy'])
--- unknown method and property, taught by the extension
compiles
--- same code without the extension
3: [Static type checking] - Cannot find matching method java.lang.String#longueur(). Please check if the declared type is correct and if the method exists.
--- scopes
3: [Static type checking] - Method a contains more than 2 method calls (found 3)
--- parameters
2: [Static type checking] - demo: f takes 2 parameters, limit is 1
compiles

The first compile succeeds only thanks to the extension; without it the checker reports that longueur() cannot be found. The scoped extension counts the calls selected inside each method and reports a (three calls) but not b (one call). The parameters extension is configured with a limit of 1 through the options, and with no options it uses its default limit of 3 and the same class compiles. Source: Groovy documentation: Working with extensions

Precompiled extensions

An extension script is compiled again for every source unit that uses it, which slows compilation. A precompiled extension is compiled once and referenced by its fully qualified class name instead of a path. Written in Groovy, the script becomes the body of run() in a subclass of GroovyTypeCheckingExtensionSupport.TypeCheckingDSL, and all the events work unchanged:

import org.codehaus.groovy.transform.stc.GroovyTypeCheckingExtensionSupport

class PrecompiledExtension extends GroovyTypeCheckingExtensionSupport.TypeCheckingDSL {
    @Override
    Object run() {
        unresolvedVariable { var ->
            if ('robot' == var.name) {
                storeType(var, classNodeFor(Robot))
                handled = true
            }
        }
    }
}

Written in Java there is no DSL: the class extends AbstractTypeCheckingExtension and overrides the handle... methods. The same extension in Java:

import org.codehaus.groovy.ast.ClassHelper;
import org.codehaus.groovy.ast.expr.VariableExpression;
import org.codehaus.groovy.transform.stc.AbstractTypeCheckingExtension;
import org.codehaus.groovy.transform.stc.StaticTypeCheckingVisitor;

public class PrecompiledJavaExtension extends AbstractTypeCheckingExtension {
    public PrecompiledJavaExtension(final StaticTypeCheckingVisitor typeCheckingVisitor) {
        super(typeCheckingVisitor);
    }

    @Override
    public boolean handleUnresolvedVariableExpression(final VariableExpression vexp) {
        if ("robot".equals(vexp.getName())) {
            storeType(vexp, ClassHelper.make(Robot.class));
            setHandled(true);
            return true;
        }
        return false;
    }
}

Both were compiled in the run for this page (groovyc -d pre Robot.groovy src/PrecompiledExtension.groovy, then javac with groovy-6.0.0.jar and the pre directory on the class path) and used by class name:

import groovy.transform.TypeChecked
['PrecompiledExtension', 'PrecompiledJavaExtension'].each { name ->
    println name
    println Driver.run('robot.move 7\nrobot.position', TypeChecked, [name], [robot: new Robot()])
}
PrecompiledExtension
7
PrecompiledJavaExtension
7

An extension may use @Grab to pull in libraries that are needed at compile time only, at the cost of a longer first compilation. To share extensions, bundle the scripts or classes in a jar on the compilation class path. Groovy has no way to apply an extension globally just by having it on the class path: it must be named in extensions or added by a compilation customizer. Source: Groovy documentation: Precompiled type checking extensions

Extensions and @CompileStatic: mixed mode

Extensions also work with @CompileStatic, with an important restriction: telling the checker about a type is not enough to tell the code generator how to call something. According to the documentation, setting handled = true on an unresolved variable triggers a dynamic lookup of the variable by default, so the robot example above also works under @CompileStatic. A method call is different. Take the script move 100, run with a groovy.util.DelegatingScript base class whose delegate is the robot, so move is resolved on the delegate at run time. An extension that declares the method with newMethod satisfies the checker, but the static compiler has no idea which method it is or what the receiver is. makeDynamic(call, returnType) fixes this: it creates the virtual method, sets handled, and marks the call so that the compiler generates a dynamic call for it, while code after it that uses the declared return type is still compiled statically. makeDynamic accepts a MethodNode, a VariableExpression or a PropertyExpression.

// ext/robotextension2.groovy -- newMethod only
methodNotFound { receiver, name, argList, argTypes, call ->
    if (isMethodCallExpression(call) && call.implicitThis && 'move' == name
            && argTypes.length == 1 && argTypes[0] == classNodeFor(int)) {
        handled = true
        newMethod('move', classNodeFor(Robot))
    }
}

// ext/robotextension3.groovy -- makeDynamic
methodNotFound { receiver, name, argList, argTypes, call ->
    if (isMethodCallExpression(call) && call.implicitThis && 'move' == name
            && argTypes.length == 1 && argTypes[0] == classNodeFor(int)) {
        makeDynamic(call, classNodeFor(Robot))
    }
}
import groovy.transform.CompileStatic
import org.codehaus.groovy.control.CompilerConfiguration
import org.codehaus.groovy.control.customizers.ASTTransformationCustomizer

def runWith = { String extension ->
    def config = new CompilerConfiguration()
    config.scriptBaseClass = 'groovy.util.DelegatingScript'
    config.addCompilationCustomizers(new ASTTransformationCustomizer(CompileStatic, extensions: [extension]))
    def loader = new URLClassLoader([new File('ext').toURI().toURL()] as URL[], Robot.classLoader)
    def script = new GroovyShell(loader, new Binding(), config).parse('move 100')
    def robot = new Robot()
    script.setDelegate(robot)
    try {
        script.run()
        println "${extension}: position=${robot.position}"
    } catch (Throwable t) {
        println "${extension}: ${t.class.simpleName}: ${t.message}"
    }
}
runWith('robotextension2.groovy')
runWith('robotextension3.groovy')
robotextension2.groovy: NoSuchMethodError: 'Robot java.lang.Object.move()'
robotextension3.groovy: position=100

The version that only declares the method compiles but fails at run time with a NoSuchMethodError, because the compiler emitted a static call to a method that does not exist on Object; the makeDynamic version runs. This is deliberate: unresolved calls are not made dynamic automatically, because then typos would stop being compile errors under @CompileStatic. Mixed mode must be asked for, so the author of the DSL knows exactly where typing rules are relaxed.

An extension used with @CompileStatic can also be used only to add errors, without relaxing anything. A checker of SQL queries is the example in the documentation: it makes sense with or without static compilation, because the code passes the standard checks anyway. Source: Groovy documentation: Type checking extensions and @CompileStatic

Transforming the AST from an extension

Since an extension has access to the AST, it could modify it. That is not recommended. Type checking is supposed only to annotate the tree, so that code behaves the same with and without @TypeChecked; and type checking is the last phase before bytecode generation, so a change made there must already be a fully valid tree because no later phase repairs it. If the extension is meant for @CompileStatic, where the semantics already differ from dynamic Groovy, changing the AST is more acceptable, but a transformation that runs before type checking is usually easier. See Writing AST Transformations & Macros.

Extensions for DSL safety

For a DSL, the pieces fit together like this: a host application compiles user scripts with a CompilerConfiguration that has a script base class or binding for the DSL vocabulary, a customizer that applies @TypeChecked (or @CompileStatic) to every script, and an extension that declares the variables and virtual methods of the vocabulary and adds the project’s own rules, as the robot examples above do. @DelegatesTo and @ClosureParams (see Closure Delegation: Owner, Delegate & This) cover the common case of a closure whose delegate type is known; an extension covers what annotations cannot express. The documentation points to real extensions in the Groovy source tree (the scripts used by the TypeCheckingExtensionsTest test class), and notes that the markup template engine, described on Template Engines, uses a complex extension plus AST transformations to turn templates into statically compiled code. Source: Groovy documentation: Domain-specific languages

Where the book is outdated

Books written before Groovy 2.0 do not cover static type checking, so they cannot cover extensions to it; extensions arrived after the checker itself, in the 2.x line. Parameterized extensions (an option list inside the extension name) and the bundled checkers in the groovy.typecheckers package are new in Groovy 6.0 and exist in no earlier book. Code samples that use the @TypeChecked(extensions=...) form from the 2.x era still work; the support classes remain in the org.codehaus.groovy.transform.stc package named in the current documentation.