Built-in Type Checkers

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). Besides the standard checks of @TypeChecked and @CompileStatic (Static Type Checking & @CompileStatic), Groovy ships a set of type checking extensions that verify one specific concern at compile time: that a regular expression is valid, that a printf format matches its arguments, that a null is not dereferenced, that a method modifies only what it declares or has no side effects, that a parallel reduction uses an associative combiner, that a monadic comprehension has the right shape, and that a SQL query is not built by quoting an interpolated value. They are ordinary extensions of the kind described on Type Checking Extensions, they live in the groovy.typecheckers package of the groovy-typecheckers module (which the binary distribution puts on the class path), and they are activated by name.

Overview

Checker What it verifies Status in 6.0

RegexChecker

Regular expression literals and constant patterns are valid, and group numbers exist, so a PatternSyntaxException cannot occur at run time.

Stable, since 4.0.0

FormatStringChecker

printf-style format strings are valid and their conversions fit the argument types.

Stable, since 5.0.0

NullChecker

Null dereferences, using nullability annotations and, in strict mode, flow analysis of unannotated code.

New in 6.0.0, incubating

ModifiesChecker

A method with a @Modifies frame condition assigns only the fields and parameters it declares.

New in 6.0.0, incubating

PurityChecker

A @Pure method has no side effects; categories of tolerated effects are configurable.

New in 6.0.0, incubating

CombinerChecker

Combiners passed to parallel reductions carry the associativity contract those methods rely on.

New in 6.0.0, incubating

MonadicChecker

Lets @CompileStatic and @TypeChecked see through the DO macro, and rejects types that cannot take part in a comprehension.

New in 6.0.0, incubating

MonadicShapeChecker

Lint for hand-written flatMap and map chains on Optional, Stream and Awaitable.

New in 6.0.0, incubating

SqlInjectionChecker

Flags a GString interpolation placed inside SQL string quotes.

New in 6.0.0, incubating

Incubating means that options and semantics may still change in a later 6.x release. The checkers match the annotations they use, such as @Nullable or @Pure, by simple name, so annotations from JSpecify, JetBrains, the Checker Framework, JSR 305 or your own definitions are all accepted. They also work only under @TypeChecked or @CompileStatic: dynamic code is not checked.

Activating a checker

The attribute is extensions, with the fully qualified name of the checker. Several can be listed as an array, and options go in parentheses after the name (parameterized extensions, new in 6.0):

@TypeChecked(extensions = 'groovy.typecheckers.RegexChecker')
class A { }

@TypeChecked(extensions = ['groovy.typecheckers.ModifiesChecker', 'groovy.typecheckers.PurityChecker'])
class B { }

@TypeChecked(extensions = 'groovy.typecheckers.NullChecker(strict: true)')
class C { }

To check a whole code base, apply them with a compilation customizer or a compiler configuration script instead of annotating every class (see Compilation Customizers & Secure Scripting). This program applies two checkers to every script compiled by a GroovyShell:

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

def config = new CompilerConfiguration()
config.addCompilationCustomizers(new ASTTransformationCustomizer(TypeChecked,
        extensions: ['groovy.typecheckers.RegexChecker', 'groovy.typecheckers.FormatStringChecker']))
def shell = new GroovyShell(config)

println shell.evaluate('''assert 'Apple' =~ /(a|A)./; sprintf('%03d', 7)''')
try {
    shell.evaluate('''def broken = ~/(a|A./''')
} catch (e) {
    println e.message.readLines().find { it.contains('Static type') }
}
try {
    shell.evaluate('''sprintf('%d', 'seven')''')
} catch (e) {
    println e.message.readLines().find { it.contains('Static type') }
}
007
Script2.groovy: 1: [Static type checking] - Bad regex: Unclosed group near index 5
Script3.groovy: 1: [Static type checking] - IllegalFormatConversion: d != java.lang.String

The RegexChecker examples below are complete files shown with the full groovyc output. To keep them short, the examples from FormatStringChecker onwards report only the error messages the compiler printed, produced by compiling each snippet with a small helper that parses the code and prints only the type checking message and its line number (the line counts from the start of the string, header lines included, and refers to the longer files used in the run rather than to the abbreviated snippets shown on this page):

class Check {
    static void compile(String label, String code) {
        try {
            new GroovyShell().parse(code)
            println "${label}: compiles"
        } catch (e) {
            def msgs = e.message.readLines().findAll { it.contains('[Static type checking]') }
                    *.replaceFirst(/^.*?(\d+): (?=\[Static)/, 'line $1: ')
            msgs ? msgs.each { println "${label}: ${it}" } : println("${label}: ${e.message.readLines().take(3)}")
        }
    }
}

RegexChecker

RegexChecker verifies regular expressions at compile time; if the pattern is accepted, it cannot fail at run time with a PatternSyntaxException. Regular expressions themselves are on Regular Expressions.

The checker examines these calls when it can see a constant pattern:

  • Pattern.compile(regex), Pattern.compile(regex, flags) and Pattern.matches(regex, input);

  • Matcher.group(int) and the matcher[i][j] shorthand, whose group numbers are checked against the group count of the pattern;

  • the pattern operator, the find operator and the match operator;

  • methods of String that take a regex: matches (since 4.0.0) and, since 6.0.0, find, findAll, findGroups, findAllGroups, eachMatch, split, splitEachLine, replaceAll and replaceFirst;

  • the RegexGuard entry points that evaluate a pattern under a deadline (new in 6.0).

A pattern is "constant-like" when it is a string literal, or a field or local variable that has an initializer. The group-count argument must be constant-like too. For a pattern that is not constant, the checker stays silent.

import groovy.transform.TypeChecked

@TypeChecked(extensions='groovy.typecheckers.RegexChecker')
static main(args) {
    assert 'foo'.matches(/[Ff].{2}\b/)
    println 'regex ok'
}

The script compiles and runs:

regex ok

Three typical mistakes in one checked method, saved as RegexBad.groovy:

import groovy.transform.TypeChecked

@TypeChecked(extensions='groovy.typecheckers.RegexChecker')
static main(args) {
    assert 'apple' =~ /(a|A./                          // missing closing parenthesis

    var m = 'foobar' =~ /(...)(...)/
    assert m.find()
    assert m.group(3) == 'bar'                         // the pattern has two groups

    var shortNamed = ~/\w{3/                           // missing closing brace
}

groovyc RegexBad.groovy rejects it with three errors:

org.codehaus.groovy.control.MultipleCompilationErrorsException: startup failed:
RegexBad.groovy: 5: [Static type checking] - Bad regex: Unclosed group near index 5
(a|A.
 @ line 5, column 12.
       assert 'apple' =~ /(a|A./                          // missing closing parenthesis
              ^

RegexBad.groovy: 11: [Static type checking] - Bad regex: Unclosed counted closure near index 4
\w{3
 @ line 11, column 22.
       var shortNamed = ~/\w{3/                           // missing closing brace
                        ^

RegexBad.groovy: 9: [Static type checking] - Invalid group count 3 for regex with 2 groups
 @ line 9, column 12.
       assert m.group(3) == 'bar'                         // the pattern has two groups
              ^

3 errors

The rest of the error categories the checker reports are: bad class syntax, bad named capturing group, dangling meta character, empty character family, illegal character range, illegal repetition, illegal or unsupported escape sequence, look-behind group without an obvious maximum length, unexpected character, unclosed character family, class, counted closure and group, unknown character property, Unicode property, group type, inline modifier and character name, and an unmatched closing parenthesis.

findGroups returns the full match followed by each capture group, which pairs with destructuring. When the pattern is known the checker rejects more variables than the match plus the groups could ever fill (FindGroups.groovy):

import groovy.transform.TypeChecked

@TypeChecked(extensions='groovy.typecheckers.RegexChecker')
static main(args) {
    def semver = /(\d+)\.(\d+)\.(\d+)(?:-(.+))?/
    def (all, major, minor, patch, qualifier) = '6.0.0-beta-2'.findGroups(semver)   // fits
    def (a, b, c, d, e, f) = '6.0.0-beta-2'.findGroups(semver)                      // one variable too many
}
org.codehaus.groovy.control.MultipleCompilationErrorsException: startup failed:
FindGroups.groovy: 7: [Static type checking] - Too many variables 6 for findGroups: a regex with 4 groups yields at most 5 elements (the full match plus each group)
 @ line 7, column 5.
       def (a, b, c, d, e, f) = '6.0.0-beta-2'.findGroups(semver)                      // one variable too many
       ^

1 error

Fewer variables than elements are accepted. A scope marked @groovy.transform.SafeRegex(millis = 200) (new in 6.0) rewrites the find and match operators into guarded RegexGuard calls, and the checker recognises the rewritten form, so the checks still apply:

import groovy.transform.SafeRegex
import groovy.transform.TypeChecked

@SafeRegex(millis = 200)
@TypeChecked(extensions='groovy.typecheckers.RegexChecker')
static main(args) {
    assert 'apple' =~ /(a|A)p/
    assert 'apple' ==~ /a\w+/
    println 'guarded and checked'
}
guarded and checked

Changing the first pattern to /(a|A p/ makes the same file fail to compile with [Static type checking] - Bad regex: Unclosed group near index 6 at line 7.

FormatStringChecker

FormatStringChecker validates the format string of printf-style methods and the arguments passed with it, so that the IllegalFormatConversionException, MissingFormatArgumentException and FlagsConversionMismatchException family cannot occur at run time. The checked methods are String.format (both overloads) and String.formatted, Formatter.format, PrintStream.printf, and the GDK printf and sprintf. The format string, an optional leading Locale, must be a constant literal, a local variable with a constant initializer or a field with a constant initializer; for the arguments, the checker uses constants where it finds them and the inferred static type otherwise.

You can have your own methods checked by annotating them with @FormatMethod, from the groovy.typecheckers package (the same-named annotation of the Java Checker Framework is also accepted):

import groovy.transform.TypeChecked
import groovy.typecheckers.FormatMethod

@FormatMethod
static String log(String formatString, Object... args) { sprintf(formatString, args) }

@TypeChecked(extensions='groovy.typecheckers.FormatStringChecker')
static main(args) {
    assert log('%x', 1023) == '3ff'      // accepted
    log('%d', 'x')                       // a String where %d needs an integer
}

The mistakes it finds, with the messages printed in the run:

sprintf '%c', true                    // the character conversion does not accept a boolean
String.format('%1.7d', 3)             // precision is meaningless for an integer
System.out.printf('%v', 7)            // unknown conversion
sprintf('%d %s', 1)                   // missing argument
format own method: line 9: [Static type checking] - IllegalFormatConversion: d != java.lang.String
format bad conversion: line 5: [Static type checking] - IllegalFormatConversion: c != java.lang.Boolean
format bad precision: line 5: [Static type checking] - IllegalFormatPrecision: 7
format unknown conversion: line 5: [Static type checking] - UnknownFormatConversion: Conversion = 'v'
format missing argument: line 5: [Static type checking] - MissingFormatArgument: Format specifier '%s'

The checker’s full list of reported errors is: duplicate flags, illegal flags, illegal precision, unknown conversions, illegal conversions, flag and conversion mismatches, and missing arguments.

NullChecker

NullChecker (new in 6.0, incubating) is Groovy’s answer to the nullable types of Kotlin or Swift, taken in a pragmatic, opt-in way. It has two modes. Plain NullChecker checks code that is annotated with nullability annotations. NullChecker(strict: true) adds flow-sensitive tracking through assignments and control flow, so it also finds potential null dereferences in unannotated code. The two can be mixed in one code base, with the plain checker on relaxed classes and the strict one on critical ones, via per-class annotations. The annotation classes you use must be resolvable on the compile class path.

Recognised annotations

Annotations are matched by simple name (declaration annotations and TYPE_USE ones, including those in compiled classes):

Meaning Annotation names

Nullable

@Nullable, @CheckForNull, @MonotonicNonNull

Non-null

@NonNull, @NotNull, @Nonnull

Non-null by default (class level)

@NonNullByDefault, @NullMarked, @ParametersAreNonnullByDefault

Opt out (class level)

@NullUnmarked

The Groovy @NullCheck annotation, which generates runtime checks, is treated as making all non-primitive parameters @NonNull; @Lazy is treated as an implicit @MonotonicNonNull. If you prefer no dependency, define your own minimal annotations. The examples that follow use this header, whose line numbers explain the line numbers in the messages:

import groovy.transform.*
import java.lang.annotation.*
@Target([ElementType.PARAMETER, ElementType.METHOD, ElementType.FIELD, ElementType.TYPE_USE])
@Retention(RetentionPolicy.CLASS)
@interface Nullable {}
@Target([ElementType.PARAMETER, ElementType.METHOD, ElementType.FIELD, ElementType.TYPE_USE])
@Retention(RetentionPolicy.CLASS)
@interface NonNull {}

Patterns that make a dereference safe

A null guard, safe navigation, an early exit, a Groovy-truth test, instanceof, Objects.nonNull, an assert, negations and short-circuit conjunctions and disjunctions are all recognised, in if, while and ternary conditions. Objects.requireNonNull(x), Guava-style checkNotNull(x), JUnit-style assertNotNull(x) and AssertJ-style assertThat(x).isNotNull() narrow x to non-null afterwards (matched by name, so any library with those conventions works). An elvis expression x ?: fallback is nullable only if the fallback is.

@TypeChecked(extensions='groovy.typecheckers.NullChecker')
int safeLength(@Nullable String text) {
    if (text != null) { return text.length() }      // guard
    return -1
}

@TypeChecked(extensions='groovy.typecheckers.NullChecker')
String greet(@Nullable String name) { name?.toUpperCase() }      // safe navigation

@TypeChecked(extensions='groovy.typecheckers.NullChecker')
class Greeter {
    static String greet(@NonNull String name) { 'Hello ' + name }
    static String welcome(@Nullable String visitor) { greet(visitor ?: 'guest') }     // elvis fallback is non-null
}

@TypeChecked(extensions='groovy.typecheckers.NullChecker')
int labelWidth(@Nullable String label) {
    Objects.requireNonNull(label, 'label must be supplied')
    label.length()                                                 // narrowed by requireNonNull
}

All four compiled in the run (guard, safe navigation, elvis and requireNonNull each reported compiles).

Errors detected

@TypeChecked(extensions='groovy.typecheckers.NullChecker')
int unsafeLength(@Nullable String text) { return text.length() }                 // (1)

@TypeChecked(extensions='groovy.typecheckers.NullChecker')
String shorten(@Nullable String text) { text?.trim().substring(0, 1) }           // (2)

@TypeChecked(extensions='groovy.typecheckers.NullChecker')
class Greeter {
    static String greet(@NonNull String name) { name.toUpperCase() }
    static void main(String[] args) { greet(null) }                              // (3)
}

@TypeChecked(extensions='groovy.typecheckers.NullChecker')
class Greeter2 {
    @NonNull static String greet() { return null }                               // (4)
}

@TypeChecked(extensions='groovy.typecheckers.NullChecker')
class Config {
    @NonNull String name
    void clear() { name = null }                                                 // (5)
}

@TypeChecked(extensions='groovy.typecheckers.NullChecker')
class UserService {
    @Nullable static String findUser() { null }
    static void main(String[] args) { findUser().toUpperCase() }                 // (6)
}
no guard: line 13: [Static type checking] - Potential null dereference: 'text' is @Nullable
safe navigation chain: line 12: [Static type checking] - Potential null dereference: 'text?.trim()' may be null
null to NonNull param: line 14: [Static type checking] - Cannot pass null to @NonNull parameter 'name' of 'greet'
return null from NonNull: line 13: [Static type checking] - Cannot return null from @NonNull method 'greet'
null to NonNull field: line 14: [Static type checking] - Cannot assign null to @NonNull variable 'name'
nullable method result: line 14: [Static type checking] - Potential null dereference: 'findUser()' may return null

The messages correspond to the six marked lines in order: dereferencing a @Nullable parameter without a guard (1), the safe navigation chain whose second step is unguarded (2, fix it with text?.trim()?.substring(0, 1)), passing null to a @NonNull parameter (3), returning null from a @NonNull method (4), assigning null to a @NonNull field (5) and dereferencing the result of a @Nullable method (6). The same applies to a @Nullable getter used with property syntax, and to @Nullable type arguments carried through generics (get(0), the subscript and head() on a List<@Nullable String>, get(key) on a map of nullable values). Wider generics support, such as nullable type-parameter bounds, wildcards and array components, is not covered yet.

Class-level annotations, contracts and @MonotonicNonNull

@NullCheck on a class or method and @ParametersAreNonnullByDefault make parameters non-null by default, while @NonNullByDefault and @NullMarked do the same for parameters, return types and fields; an explicit @Nullable overrides it. When the groovy-contracts module is on the class path, @Requires({ x != null }) marks x as non-null and @Ensures({ result != null }) marks the return, both only through top-level conjuncts (a disjunction proves nothing about one operand). A @MonotonicNonNull field, or a @Lazy field, may start as null, must be guarded until assigned, and cannot be set back to null:

@NullCheck
@TypeChecked(extensions='groovy.typecheckers.NullChecker')
class Greeter {
    static String greet(String name) { "Hello, $name!" }
    static void main(String[] args) { greet(null) }                // @NullCheck: name is effectively @NonNull
}

@TypeChecked(extensions='groovy.typecheckers.NullChecker')
class Greeter3 {
    @Requires({ name != null })
    static String greet(name) { "Hello, $name!" }
    static void main(String[] args) { greet(null) }                // inferred from the precondition
}

@TypeChecked(extensions='groovy.typecheckers.NullChecker')
class Lazy1 {
    @MonotonicNonNull String value
    void reset() { value = 'hello'; value = null }                 // cannot re-null
}
NullCheck: line 15: [Static type checking] - Cannot pass null to @NonNull parameter 'name' of 'greet'
requires: line 8: [Static type checking] - Cannot pass null to @NonNull parameter 'name' of 'greet'
monotonic: line 8: [Static type checking] - Cannot assign null to @MonotonicNonNull variable 'value' after non-null assignment

(In the run @MonotonicNonNull was defined in the script as a custom annotation with the same simple name, and @Requires came from groovy.contracts.) Design by contract is on Design by Contract with Groovy Contracts.

Strict mode

Without strict, nothing is reported for unannotated code. Strict mode tracks nullability through assignments and control flow, including the results of safe navigation, @Nullable calls and nullable ternaries, and the elvis assignment x ?= value clears a nullable state. It also checks that each explicitly annotated @NonNull instance field is definitely initialised: at its declaration, in an initializer block or by every constructor (a constructor that delegates with this(...) relies on its delegate). Fields that are only non-null by default are not checked.

@TypeChecked(extensions='groovy.typecheckers.NullChecker(strict: true)')
static main(args) {
    def x = null
    x.toString()                       // x may be null
}

@TypeChecked(extensions='groovy.typecheckers.NullChecker(strict: true)')
static main2(args) {
    def x = null
    x = 'hello'
    assert x.toString() == 'hello'     // reassigned, so fine
}

@TypeChecked(extensions='groovy.typecheckers.NullChecker(strict: true)')
class Library {
    @NonNull String catalog            // never assigned
    Library(String catalog) { }
}
strict null local: line 6: [Static type checking] - Potential null dereference: 'x' may be null
strict reassigned: compiles
non strict null local: compiles
strict uninit field: line 13: [Static type checking] - @NonNull field 'catalog' is not initialized by all constructors

In order, the output lists the strict-mode dereference, the reassigned variable that compiles, the first snippet’s code under the plain NullChecker, which compiles because strict mode is what tracks unannotated code, and the uninitialised @NonNull field. The list of what the checker detects: null assigned to a @NonNull field or variable, null or a @Nullable value passed to a @NonNull parameter or returned from a @NonNull method (explicit or implied), dereferencing a @Nullable variable, method result or generic type argument, re-nulling a @MonotonicNonNull or @Lazy field, and, in strict mode only, dereferencing a variable known to be null and an uninitialised @NonNull field. The documentation recommends CodeNarc rules for complementary null-related lint, such as broken null-check logic.

ModifiesChecker

ModifiesChecker (new in 6.0, incubating) checks the frame condition declared by @Modifies, from groovy.contracts: the fields and parameters a method may change, which implies that everything else is unchanged. It is opt-in per method: only methods annotated @Modifies are checked.

import groovy.transform.TypeChecked
import groovy.contracts.Modifies

@TypeChecked(extensions = 'groovy.typecheckers.ModifiesChecker')
class InventoryItem {
    int quantity = 0
    int reserved = 0
    @Modifies({ [this.quantity, this.reserved] })
    void restock(int amount) {
        quantity += amount
        reserved = 0
    }
}

That class compiled. Violations are errors for direct field assignment (this.f = ..., f = ..., f++, f += ...; local variables are always fine) and warnings, reported as the same kind of compile message, for calls whose effect cannot be shown to be harmless:

@TypeChecked(extensions = 'groovy.typecheckers.ModifiesChecker')
class Counter {
    int count
    int other
    @Modifies({ this.count })
    void broken() { other = 5 }                 // not declared
}

@TypeChecked(extensions = 'groovy.typecheckers.ModifiesChecker')
class Lists {
    int count
    @Modifies({ list1 })
    void process(List list1, List list2) {
        list1.add('x')                           // list1 is declared
        list2.size()                             // size() is known to be safe
        list2.clear()                            // may modify list2
    }
}
modifies undeclared: line 10: [Static type checking] - @Modifies violation: assignment to 'other' but 'other' is not declared in @Modifies
modifies param call: line 11: [Static type checking] - @Modifies warning: call to 'list2.clear()' may modify 'list2' which is not in @Modifies

For calls on this, the callee’s own @Modifies must be a subset of the caller’s, and a @Pure callee is always safe; for calls on parameters and variables not in the frame, the checker lets through immutable receiver types (String, Integer, BigDecimal, LocalDate and so on), @Pure methods and a curated list of non-mutating names (size, get, contains, isEmpty, toString, iterator, stream…​) and warns otherwise. Purity annotations from other libraries are understood by simple name: @Pure and @SideEffectFree mean an empty frame, and the JetBrains @Contract(pure = true) does too, while @Contract(mutates = "param1") and similar strings are read as a frame condition on callees.

PurityChecker

PurityChecker (new in 6.0, incubating) goes beyond ModifiesChecker: it verifies that a @Pure method (from groovy.transform) has no side effects. By default purity is strict. The allows option lists effect categories to tolerate, separated by a vertical bar: LOGGING (logging calls, println), METRICS (metrics instruments), IO (file, network, database and console I/O) and NONDETERMINISM (time, random numbers, environment, UUID.randomUUID()).

@TypeChecked(extensions = 'groovy.typecheckers.PurityChecker')
class Calculator {
    int count
    @Pure int add(int a, int b) { a + b }                 // fine
    @Pure int broken() { count++; return count }          // field mutation
}

@TypeChecked(extensions = 'groovy.typecheckers.PurityChecker')
class MathUtils {
    @Pure int square(int x) { x * x }
    @Pure long timestamp() { System.nanoTime() }          // non-deterministic
    @Pure int logged(int x) { println("x=$x"); x * 2 }    // logging
}

@TypeChecked(extensions = 'groovy.typecheckers.PurityChecker(allows: "LOGGING")')
class Service {
    @Pure int compute(int x) { println("computing $x"); return x * x }   // allowed
    @Pure long getTime() { System.nanoTime() }                            // still not allowed
}

@TypeChecked(extensions = 'groovy.typecheckers.PurityChecker(allows: "LOGGING|NONDETERMINISM")')
class Diagnostics {
    @Pure String snapshot(Map state) { println("at ${System.nanoTime()}"); state.toString() }
}
purity ok: compiles
purity field mutation: line 7: [Static type checking] - @Pure violation: field assignment to 'count'
purity strict: line 8: [Static type checking] - @Pure violation: 'java.lang.System.nanoTime()' is non-deterministic (allow with NONDETERMINISM)
purity strict: line 10: [Static type checking] - @Pure violation: 'println()' is a logging/output call (allow with LOGGING)
purity allows logging: line 11: [Static type checking] - @Pure violation: 'java.lang.System.nanoTime()' is non-deterministic (allow with NONDETERMINISM)
purity allows both: compiles

The first line is Calculator with only add, which compiles; the last is Diagnostics, which compiles because both effects are allowed. A call is classified in layers: a callee marked @Pure, @SideEffectFree or @Contract(pure = true) is pure; a @Memoized callee is effectively pure; calls on immutable receivers and on a list of known pure names such as size and toString are pure; a call matching an effect category is an error unless allowed; anything else produces a warning that purity cannot be verified. The strictness chosen in allows is machine-readable and says what a caller may do with a @Pure method: for example, with allows: "LOGGING" the method is deterministic and can be reordered or cached, but dead-call elimination would lose its output.

The checker is not complete. It does not follow mutation through aliases, deep heap mutation, chains such as a.b.c = 1, or side effects inside closures passed to higher-order methods. Common cases are caught and unverifiable calls produce warnings.

CombinerChecker

The parallel collection reductions sumParallel and injectParallel split, reorder and recombine their input. That is only correct when the combiner is associative, and for the seeded injectParallel the seed must be an identity of the combiner. A subtraction compiles and runs but gives different results depending on how work was partitioned, which is a hard bug to find by testing. (Associativity is not commutativity: string concatenation is associative but not commutative, and is a valid combiner over an ordered source.) CombinerChecker (new in 6.0, incubating) works with two annotations in groovy.transform: @Associative declares a method as an associative combiner, and @Reducer(zero = '0') also names its identity element. Parallel collections are on Parallel Collections & Dataflow.

The default mode is lenient: it flags only high-confidence problems, which are a non-associative operator (-, /, %, **) applied directly to the two combiner parameters and a seed that contradicts the zero of a @Reducer. A plain closure such as { a, b -> a + b } is never flagged. The strict mode also requires the combiner to be a method reference to an annotated method, so inline closures are rejected.

class Maths {
    @Associative static int add(int a, int b) { a + b }
    @Reducer(zero = '0') static int plus(int a, int b) { a + b }
}

@TypeChecked(extensions = 'groovy.typecheckers.CombinerChecker')
def reduce() {
    [1, 2, 3].injectParallel(0, Maths.&add)               // declared @Associative
    [1, 2, 3].injectParallel(0) { a, b -> a + b }         // lenient: associative operator
}

@TypeChecked(extensions = 'groovy.typecheckers.CombinerChecker')
def subtract() { [1, 2, 3].injectParallel(0) { a, b -> a - b } }              // non-associative

@TypeChecked(extensions = 'groovy.typecheckers.CombinerChecker')
def seeded() { [1, 2, 3].injectParallel(5, Maths.&plus) }                     // seed is not the declared zero

@TypeChecked(extensions = "groovy.typecheckers.CombinerChecker(mode: 'strict')")
def strict() { [1, 2, 3].injectParallel(0) { a, b -> a + b } }               // strict: inline combiner

@TypeChecked(extensions = 'groovy.typecheckers.CombinerChecker')
def stream() { [1, 2, 3].stream().reduce(0) { a, b -> a - b } }              // java.util.stream
combiner lenient: compiles
combiner subtraction: line 5: [Static type checking] - CombinerChecker: combiner passed to 'injectParallel' applies a non-associative operator to its arguments; parallel reduction will be non-deterministic. Use an associative combiner.
combiner seed: line 8: [Static type checking] - CombinerChecker: seed '5' passed to 'injectParallel' does not match the @Reducer(zero='0') of 'plus'.
combiner strict: line 5: [Static type checking] - CombinerChecker (strict): inline combiner passed to 'injectParallel' is not declared @Associative/@Reducer; extract a named, annotated method.
combiner stream: line 5: [Static type checking] - CombinerChecker: combiner passed to 'reduce' applies a non-associative operator to its arguments; parallel reduction will be non-deterministic. Use an associative combiner.

The first method compiled; the others reproduce the four messages in order. The checker also covers reduce on Stream, IntStream, LongStream and DoubleStream: Stream.reduce is required to be associative even for sequential streams, unlike Groovy’s sequential inject, which is a deterministic left fold and is never flagged. A combiner that comes from a Monoid or Semigroup type of an external library (matched by simple type name, extensible through options) is accepted on trust, like @Associative; a seeded injectParallel with a Semigroup is flagged because a semigroup has no identity.

It is a safety net, not a proof. Associativity cannot be proved, only asserted by annotation or ruled out in obvious cases; the annotations should be backed by tests. An inline lambda has nowhere to carry the annotation. The inline check is syntactic, so an overloaded operator can fool it in either direction. Pair it with PurityChecker, since a parallel combiner should also be free of side effects.

MonadicChecker and the DO macro

The DO macro (new in 6.0, incubating) writes monadic comprehensions over Optional, Stream, CompletableFuture, Awaitable, DataflowVariable, FunctionalJava and Vavr control types and user types marked @Monadic. It is described with the other functional features on Functional Programming. The macro desugars into runtime-dispatched Comprehensions.bind and Comprehensions.map calls, so without help the generator variables are Object and the body does not type check. MonadicChecker teaches @TypeChecked and @CompileStatic about the macro’s output. It also rejects a type that cannot take part (it must be in the allow-list, expose flatMap and map, or be annotated @Monadic) and a body whose closure does not return the same carrier.

import groovy.transform.TypeChecked
import static org.apache.groovy.macrolib.MacroLibGroovyMethods.DO

@TypeChecked(extensions = 'groovy.typecheckers.MonadicChecker')
Optional<String> greet(Map<String, String> users, String userId) {
    DO(user in Optional.ofNullable(users[userId]),
       name in Optional.ofNullable(user.split(/\|/)[0])) {
        Optional.of("Hello, $name!".toString())
    }
}
assert greet([u42: 'Alice|admin'], 'u42').get() == 'Hello, Alice!'
assert greet([u42: 'Alice|admin'], 'u99') == Optional.empty()
println greet([u42: 'Alice|admin'], 'u42')
Optional[Hello, Alice!]

The generator notation in 6.0.0 is name in expression, as in the release notes. The compact x <- Optional.of(1); yield x + 1 form shown in the summary table of the SDK guide did not compile in this run (the compiler answered DO requires at least one 'name in expression' generator and a trailing closure body), so do not copy it. Misuses are reported at compile time:

@TypeChecked(extensions = 'groovy.typecheckers.MonadicChecker')
def bad() { DO(x in 'text') { x } }                    // String is not a carrier

@TypeChecked(extensions = 'groovy.typecheckers.MonadicChecker')
def wrong() { DO(x in Optional.of(1)) { x + 1 } }      // body must produce an Optional
monadic carrier: line 6: [Static type checking] - Type java.lang.String does not participate in monadic comprehensions (DO): no bind (flatMap-shaped) method (not in the standard carrier allow-list, has no structural 'flatMap' method, and is not annotated @Monadic)
monadic wrong shape: line 6: [Static type checking] - Closure passed to Comprehensions.bind on java.util.Optional must yield java.util.Optional; got java.lang.Integer (not a carrier). In a DO comprehension, the body must produce the same carrier (e.g. java.util.Optional.of(...)).

Without the extension, a @TypeChecked method using DO fails with errors about Object#plus(int) and the Object return value (the run printed Cannot find matching method java.lang.Object#plus(int) and a second error about the return type), which is the problem the checker exists to solve.

MonadicShapeChecker

MonadicShapeChecker (new in 6.0, incubating) is independent of DO. It is a lint for hand-written chains over the standard carriers (Optional, Stream, Awaitable) and catches two classic shape mistakes: a flatMap closure that returns a value that is not the carrier, and a map closure that returns the same carrier, which produces a nested Optional<Optional<...>> where flatMap was meant. It has a strict option for tighter checks; the default flags only high-confidence cases. It composes with MonadicChecker, so code that mixes both styles can list both.

@TypeChecked(extensions = 'groovy.typecheckers.MonadicShapeChecker')
def bind() { Optional.of(1).flatMap { x -> x + 1 } }              // returns Integer, not Optional

@TypeChecked(extensions = 'groovy.typecheckers.MonadicShapeChecker')
def mapped() { Optional.of(1).map { x -> Optional.of(x) } }       // returns an Optional, use flatMap

@TypeChecked(extensions = 'groovy.typecheckers.MonadicShapeChecker')
def fine() { Optional.of(1).flatMap { x -> Optional.of(x + 1) } }
monadic shape bind: line 4: [Static type checking] - MonadicShapeChecker: 'flatMap' on java.util.Optional expects its function to return another java.util.Optional; got java.lang.Integer.
monadic shape map: line 4: [Static type checking] - MonadicShapeChecker: 'map' on java.util.Optional returns its function's result wrapped, producing java.util.Optional<java.util.Optional<...>>; did you mean 'flatMap'?
monadic shape ok: compiles

SqlInjectionChecker

SqlInjectionChecker (new in 6.0, incubating) flags a common SQL-injection smell: a GString whose interpolated value sits inside SQL string quotes. The value is then concatenated into the query text instead of being bound as a PreparedStatement parameter (CWE-89). It is the compile-time half of a defence in depth, the other half being groovy-sql, which rejects the same quoted-interpolation pattern at run time when the query runs. See Databases with Groovy SQL.

import groovy.sql.Sql

@TypeChecked(extensions = 'groovy.typecheckers.SqlInjectionChecker')
void findUser(Sql sql, String name) {
    sql.rows("SELECT * FROM users WHERE name = '$name'")        // flagged: quoted
}

@TypeChecked(extensions = 'groovy.typecheckers.SqlInjectionChecker')
void findUser2(Sql sql, String name) {
    sql.rows("SELECT * FROM users WHERE name = $name")          // fine: bound as a parameter
}
sql injection flagged: line 6: [Static type checking] - Possible SQL injection: the interpolated value ${name} is surrounded by SQL quotes, which prevents it from being bound as a JDBC PreparedStatement parameter. Remove the surrounding quotes so the value is bound safely.
sql injection ok: compiles

The checker is only a lint on quoting. It does not make a query that concatenates unquoted input, or builds SQL some other way, safe. It was compiled here against groovy.sql.Sql without a database connection; no query was executed.

Where the book is outdated

A book written before Groovy 4.0 knows none of these checkers. RegexChecker arrived in Groovy 4.0.0 and FormatStringChecker in 5.0.0; NullChecker, ModifiesChecker, PurityChecker, CombinerChecker, MonadicChecker, MonadicShapeChecker and SqlInjectionChecker, together with parameterized extensions, are new in 6.0.0 and still incubating. A book that describes static type checking only as a way to reject typos misses that Groovy now also uses it to verify properties of the program (nullability, purity, frame conditions).