1. Introduction
Groovy’s static nature includes an extensible type-checking mechanism. This mechanism allows users to selectively strengthen or weaken type checking as needed to cater for scenarios where the standard type checking isn’t sufficient.
In addition to allowing you to write your own custom checkers, Groovy offers a suite of built-in type checkers that provide additional compile-time verification for specific concerns:
| Checker | What it does |
|---|---|
Validates regex patterns at compile time — no more assert 'Apple' =~ /(a|A)./ // okay assert 'apple' =~ /(a|A./ // compile error: unclosed group |
|
Validates String.format('%d: %s', 42, 'ok') // okay
String.format('%d: %s', 'x', 42) // IllegalFormatConversion: d != String
|
|
Detects potential null dereferences using annotations and optional flow analysis if (name != null) name.size() // null guard name?.size() // safe navigation name.size() // potential null dereference |
|
Verifies methods only modify declared fields — humans and AI know what changed and what didn’t @Modifies({ this.balance })
void deposit(n) { balance += n } // balance is declared
@Modifies({ })
void withdraw(n) { balance -= n } // balance not in @Modifies
|
|
Enforces that @Pure int twice() { value * 2 } // reads only
@Pure int bad() { count++ } // field mutation
|
These checkers work with annotations from multiple libraries (JSpecify, JetBrains, Checker Framework, and others) — matching by simple name so you can use whichever annotation library your project already depends on.
1.1. Why these checkers matter
In a world which seems to be having an ever-increasing focus on AI, here are numerous reasons you might want to make liberal use of checkers and their annotations:
-
The checker declarations and associated annotations, along with other Groovy annotations used with AST transforms, e.g.
@Invariantfromgroovy-contracts, form a declarative specification of program behavior. This specification replaces "read the body" reasoning allowing humans and AI to understand system behavior at scale. -
When an AI agent generates or modifies code, no human may fully understand the result. Type checkers act as a compile-time safety net on what those programs can do.
-
If we can treat our systems as composable black boxes each with well specified behavior we can more easily start to apply compositional reasoning to our systems.
-
Groovy’s dynamic features (metaprogramming, runtime dispatch, invokeMethod) are powerful but can make static reasoning hard. AI models can hallucinate about what dynamic code does. Type checkers let teams selectively fill in the missing gaps in static reasoning, that AI would otherwise struggle with.
-
As AI agents increasingly write, review, and deploy code autonomously, we need machine-checkable invariants which form trust boundaries in agentic workflows.
As an example, let’s explore how combining some checkers and design-by-contract annotations allow both humans and AI to reason about method behavior without reading method implementations. Consider this annotated class:
@Invariant({ balance >= 0 })
class Account {
BigDecimal balance = 0
List<String> log = []
@Requires({ amount > 0 })
@Ensures({ balance == old.balance + amount })
@Modifies({ [this.balance, this.log] })
void deposit(BigDecimal amount) {
balance += amount
log.add("deposit $amount")
}
@Requires({ amount > 0 && amount <= balance })
@Ensures({ balance == old.balance - amount })
@Modifies({ [this.balance, this.log] })
void withdraw(BigDecimal amount) {
balance -= amount
log.add("withdraw $amount")
}
@Pure
BigDecimal available() { balance }
}
When analyzing a sequence of calls:
account.deposit(100)
account.withdraw(30)
def bal = account.available()
With annotations, each call is a self-contained specification — 3 linear reasoning steps:
-
deposit(100):@Requiresmet (100 > 0),@Ensuresgivesbalance == old + 100,@Modifiesproves onlybalanceandlogchanged -
withdraw(30):@Requiresmet (30 > 0, 30 ⇐ 100),@Ensuresgivesbalance == 100 - 30 = 70,@Modifiesproveswithdrawdidn’t undo the deposit -
available():@Pureproves no side effects — just returnsbalance(70)
This is human and AI reasoning that scales!
Without annotations, the analyzer must read every method body, verify what each one
modifies (2 fields × 3 calls = 6 "did this change?" questions), re-verify earlier state
after later calls, and check whether available() has hidden side effects. This grows
as O(fields × calls × call_depth).
1.2. Using the checkers
In the examples which follow, we explicitly show declaring the type checker to use. Groovy’s compiler customization mechanisms allow you to simplify application of such checkers, e.g. make it apply globally using a compiler configuration script, as just one example.
2. Checking Regular Expressions
A regular expression (regex) defines a search pattern for text. The pattern can be as simple as a single character, a fixed string, or a complex expression containing special characters describing a pattern. The JDK has special regex classes and Groovy adds some special syntactic sugar and functionality on top of the JDK classes.
Here is an example the checks that the String 'Foo' matches a regular expression:
assert 'foo'.matches(/[Ff].{2}\b/)
The regex pattern is made up of three parts:
-
[Ff]is a character class matching upper or lowercase 'f', -
.{2}means exactly two occurrences of the.character class which matches any character -
\bmatches a word boundary
There are more options we could add here such as grouping, lookahead and look-behind, to name just a few. With so many options and special characters, it isn’t hard to create an invalid regular expression pattern. Such invalid patterns are typically found at runtime with a runtime exception.
The goal of the RegexChecker is to find such errors at compile-time.
If compilation of the expressions succeeds, they are guaranteed not to fail at runtime.
2.1. Typical usage
The previous example can be type checked as follows:
@TypeChecked(extensions='groovy.typecheckers.RegexChecker')
static main(args) {
assert 'foo'.matches(/[Ff].{2}\b/)
}
If you have many examples where such checking is desirable, you could consider automatically adding this extension to your compilation process.
2.2. Covered methods
The methods which are checked include:
|
|
Operators
|
The RegexChecker will check any method calls listed above. It might find such methods when they involve explicit static method calls or when the receiver’s type can be inferred.
The regular expression must be a constant-like String. Any group-count values must be a constant-like integer. By constant-like, we mean, either an explicit String literal, or a field with an initializer expression or a local variable with an initializer expression. These possibilities are shown in the following code, which contains 3 regular expressions:
static final String TWO_GROUPS_OF_THREE = /(...)(...)/ (1)
@TypeChecked(extensions='groovy.typecheckers.RegexChecker')
static main(args) {
var m = 'foobar' =~ TWO_GROUPS_OF_THREE // (2)
assert m.find()
assert m.group(1) == 'foo'
assert m.group(2) == 'bar' (3)
var pets = ['cat', 'dog', 'goldfish']
var shortNamed = ~/\w{3}/ (4)
assert pets.grep(shortNamed) == ['cat', 'dog'] (5)
assert Pattern.matches(/(\d{4})-(\d{1,2})-(\d{1,2})/, '2020-12-31') (6)
}
| 1 | A constant String regex |
| 2 | Using the constant regex with the find operator |
| 3 | Checking the matcher’s second group |
| 4 | A local variable containing a regex pattern |
| 5 | Using the pattern with grep |
| 6 | An API call passing a String literal regex |
2.3. Errors detected
Luckily, in the above example, we made no errors in our regex definitions, and the code compiles and executes successfully.
If however, we did make certain errors in those definitions, then we would expect the potential of runtime errors. Using RegexChecker, we can
find certain kinds of errors during compilation.
Let’s look at some examples.
Suppose at ❸ we looked for the third group instead of the second:
assert m.group(3) == 'bar'
We would see the following error at compile-time:
[Static type checking] - Invalid group count 3 for regex with 2 groups
Alternatively, suppose at ❹ we accidentally left off the closing curly brace:
var shortNamed = ~/\w{3/
We would see the following error at compile-time:
[Static type checking] - Bad regex: Unclosed counted closure near index 4
Alternatively, suppose at ❻ we accidentally left off the closing round bracket for the last (day of month) group:
assert Pattern.matches(/(\d{4})-(\d{1,2})-(\d{1,2}/, '2020-12-31')
We would see the following error at compile-time:
[Static type checking] - Bad regex: Unclosed group near index 26
Over-and-above these examples, detected errors include:
-
Bad class syntax
-
Bad named capturing group
-
Dangling meta character
-
Empty character family
-
Illegal character range
-
Illegal repetition
-
Illegal/unsupported escape sequence
-
Look-behind group does not have an obvious maximum length
-
Unexpected character
-
Unclosed character family
-
Unclosed character class
-
Unclosed counted closure
-
Unclosed group
-
Unknown character property name
-
Unknown Unicode property
-
Unknown group type
-
Unknown inline modifier
-
Unknown character name
-
Unmatched closing ')'
3. Checking Format Strings
The format methods in java.util.Formatter, and other similar methods,
support formatted printing in the style of C’s printf method with a
format string and zero or more arguments.
Let’s consider an example which produces a string comprised of three terms:
-
a floating-point representation (
%f) of PI (with 2 decimal places of precision), -
the hex representation (
%X) of 15 (as two uppercase digits with a leading 0), -
and the Boolean (
%B)True(in uppercase).
The assertion checks our expectations:
assert String.format('%4.2f %02X %B', Math.PI, 15, true) == '3.14 0F TRUE'
This is a powerful method supporting numerous conversions and flags. If the developer supplies incorrect conversions or flags, they will receive one of numerous possible runtime errors. As examples, consider the following mistakes and resulting runtime exceptions:
-
supplying a String as the parameter for either of the first two arguments results in an
IllegalFormatConversionException, -
leaving out the last argument results in a
MissingFormatArgumentException, -
supplying the leading zero flag for the Boolean parameter results in a
FlagsConversionMismatchException.
The goal of the FormatStringChecker is to eliminate
a large number of such runtime errors. If the API call passes type checking,
it will be guaranteed to succeed at runtime.
3.1. Typical usage
Here is an example of correctly using some of these methods with type checking in place:
@TypeChecked(extensions='groovy.typecheckers.FormatStringChecker')
static main(args) {
assert String.format('%x', 1023) == '3ff'
assert String.format(Locale.US, '%x', 1024) == '400'
assert sprintf('%s%s', 'foo', 'bar') == 'foobar'
assert new Formatter().format('xy%s', 'zzy').toString() == 'xyzzy'
def baos = new ByteArrayOutputStream()
new PrintStream(baos).printf('%-4c|%6b', 'x' as char, true)
assert baos.toString() == 'x | true'
}
Over and above these methods, if you have your own method of this style,
you can annotate it with @FormatMethod, and it will also be checked.
@FormatMethod
static String log(String formatString, Object... args) {
sprintf(formatString, args)
}
@TypeChecked(extensions='groovy.typecheckers.FormatStringChecker')
static main(args) {
assert log('%x', 1023) == '3ff'
assert log('%x', 1024) == '400'
}
You can use the FormatMethod annotation provided in the groovy-typecheckers
module or the similarly named annotation from the Java-based checker framework.
3.2. Covered methods
The format string methods have the following characteristics:
-
The first argument must be of type
java.util.Localeorjava.lang.String. -
If the first argument is of type
Locale, the second argument must be of typeString. -
The
Stringargument is treated as a format string containing zero or more embedded format specifiers as well as plain text. The format specifiers determine how the remaining arguments will be used within the resulting output.
The FormatStringChecker ensures that the format string is valid and the remaining arguments are compatible with the embedded format specifiers.
The methods which are checked include:
|
|
|
DGM methods
|
The format string checker looks for a constant literal format string. For arguments, it also looks for constant literals or otherwise makes checks based on inferred type. When looking for constants, the checker will find inline literals, local variables with a constant initializer, and fields with a constant initializer. Here are some examples:
static final String FIELD_PATTERN = '%x'
static final int FIELD_PARAM = 1023
@TypeChecked(extensions='groovy.typecheckers.FormatStringChecker')
static main(args) {
assert sprintf('%x', 1024) == '400' (1)
assert sprintf(FIELD_PATTERN, FIELD_PARAM) == '3ff' (2)
var local_var_pattern = '%x'
var local_var_param = 1022
assert sprintf(local_var_pattern, local_var_param) == '3fe' (3)
assert sprintf('%x%s', theParam(), 'a' + 'b') == '3fdab' (4)
}
static int theParam() { 1021 }
| 1 | Constant literals |
| 2 | Fields with an initializer |
| 3 | Local variables with an initializer |
| 4 | Checking of the parameter will be based on inferred type |
3.3. Errors detected
Here are some examples of the kinds of errors detected.
-
The arguments much match the type of the format classifier, e.g. we can’t use the character classifier with a boolean argument:
sprintf '%c', trueWe would see the following error at compile-time:
[Static type checking] - IllegalFormatConversion: c != java.lang.Boolean -
We shouldn’t specify precision for non-scientific arguments, e.g. we shouldn’t expect 7 decimal places of precision for an integral value:
String.format('%1.7d', 3)We would see the following error at compile-time:
[Static type checking] - IllegalFormatPrecision: 7 -
We should specify only known format specifier conversions, e.g.
visn’t known:System.out.printf('%v', 7)We would see the following error at compile-time:
[Static type checking] - UnknownFormatConversion: Conversion = 'v'
The complete list of errors detected include:
-
duplicate format flags
-
illegal format flags
-
illegal format precision values
-
unknown format conversions
-
illegal format conversions
-
format flags conversion mismatches
-
missing arguments
4. Checking Null Safety (Incubating)
Null pointer dereferences are one of the most common sources of runtime errors.
The NullChecker performs compile-time null-safety analysis, detecting potential
null dereferences and null-safety violations before code is executed.
Languages like Kotlin and Swift distinguish nullable and non-nullable types throughout
the type system, eliminating null errors by construction. Groovy takes a more pragmatic
approach: flow analysis can catch many null issues without annotations (in strict mode),
and @Nullable/@NonNull annotations provide additional precision where needed.
This lets you adopt null safety incrementally — annotate the critical boundaries first,
and let flow analysis handle the rest.
Two modes are provided:
-
NullChecker— Annotation-based: Checks code annotated with@Nullableand@NonNull(or equivalent) annotations. -
NullChecker(strict: true)— Annotation-based + flow-sensitive: Includes all checks fromNullCheckerplus flow-sensitive tracking through assignments and control flow, detecting potential null dereferences even in unannotated code.
For code bases with a mix of strictness requirements, apply NullChecker to relaxed
code and NullChecker(strict: true) to strict code via per-class or per-method @TypeChecked annotations.
4.1. Typical usage
Here is an example showing a method that safely handles a @Nullable parameter:
@TypeChecked(extensions='groovy.typecheckers.NullChecker')
int safeLength(@Nullable String text) {
if (text != null) {
return text.length() // ok: null guard
}
return -1
}
assert safeLength('hello') == 5
assert safeLength(null) == -1
The checker verifies that text is only dereferenced inside the null guard.
Without the if (text != null) guard, the checker would report a compile-time error.
4.2. Supported annotations
The checker recognizes nullability annotations by their simple name, so annotations from any package are supported. The following annotation names are recognized:
Nullable |
|
Non-null |
|
Non-null by default (class-level) |
|
Opt-out (class-level) |
|
The annotation classes must be on the compile classpath so the Groovy compiler can resolve them. Since the checker matches by simple name, you are free to choose any annotation library:
-
JSpecify:
@Nullable/@NullMarked/@NullUnmarked -
javax.annotation.Nullable/javax.annotation.Nonnull(JSR-305) -
org.jetbrains.annotations.Nullable/org.jetbrains.annotations.NotNull -
edu.umd.cs.findbugs.annotations.Nullable/edu.umd.cs.findbugs.annotations.NonNull -
org.checkerframework.checker.nullness.qual.Nullable/org.checkerframework.checker.nullness.qual.NonNull
If you prefer not to add an external dependency, you can define your own minimal annotations:
import java.lang.annotation.*
@Target([ElementType.PARAMETER, ElementType.METHOD, ElementType.FIELD])
@Retention(RetentionPolicy.CLASS)
@interface Nullable {}
@Target([ElementType.PARAMETER, ElementType.METHOD, ElementType.FIELD])
@Retention(RetentionPolicy.CLASS)
@interface NonNull {}
The following class-level annotations are also recognized:
-
@NullMarked(JSpecify) — when applied to a class, all parameters, return types, and fields are treated as@NonNullunless explicitly annotated@Nullable. Use@NullUnmarkedon a nested class to opt out of this default. -
@NonNullByDefault(SpotBugs, Eclipse JDT) — same semantics as@NullMarked. -
@ParametersAreNonnullByDefault(JSR-305) — when applied to a class, all non-primitive parameters are treated as@NonNullunless explicitly annotated@Nullable. -
@groovy.transform.NullCheck— when applied to a method or class, all non-primitive parameters are treated as effectively@NonNull(unless explicitly annotated@Nullable). This provides compile-time null checking complementing the runtime checks that@NullCheckgenerates.
4.3. Null-safe access patterns
The checker recognizes several patterns for safely working with nullable values.
Safe navigation using the ?. operator:
@TypeChecked(extensions='groovy.typecheckers.NullChecker')
String greet(@Nullable String name) {
name?.toUpperCase() // ok: safe navigation
}
assert greet('world') == 'WORLD'
assert greet(null) == null
Null guards using if (x != null):
@TypeChecked(extensions='groovy.typecheckers.NullChecker')
String process(@Nullable String input) {
if (input != null) {
return input.toUpperCase() // ok: null guard
}
return 'default'
}
Early exit using if (x == null) return or throw:
@TypeChecked(extensions='groovy.typecheckers.NullChecker')
String process(@Nullable String input) {
if (input == null) return 'default' // early exit
input.toUpperCase() // ok: input is non-null here
}
Ternary and elvis expressions: in flow-sensitive mode (NullChecker(strict: true)), the checker
examines both branches of ternary (condition ? a : b) and elvis (x ?: fallback) expressions.
If either branch can be null, the result is treated as nullable. The elvis assignment
x ?= nonNullValue clears any nullable state on x.
4.4. Errors detected
Here are some examples of the kinds of errors detected.
-
Dereferencing a
@Nullablevariable without a null check:def greet = { @Nullable String name -> name.toUpperCase() // potential null dereference }We would see the following error at compile-time:
[Static type checking] - Potential null dereference: 'name' is @Nullable -
Passing
nullto a@NonNullparameter:@TypeChecked(extensions='groovy.typecheckers.NullChecker') class Greeter { static String greet(@NonNull String name) { name.toUpperCase() } static void main(String[] args) { greet(null) // cannot pass null } }We would see the following error at compile-time:
[Static type checking] - Cannot pass null to @NonNull parameter 'name' of 'greet' -
Returning
nullfrom a@NonNullmethod:@TypeChecked(extensions='groovy.typecheckers.NullChecker') class Greeter { @NonNull static String greet() { return null // cannot return null } }We would see the following error at compile-time:
[Static type checking] - Cannot return null from @NonNull method 'greet' -
Assigning
nullto a@NonNullfield:@TypeChecked(extensions='groovy.typecheckers.NullChecker') class Config { @NonNull String name void clear() { name = null // cannot assign null } }We would see the following error at compile-time:
[Static type checking] - Cannot assign null to @NonNull variable 'name' -
Dereferencing the return value of a
@Nullablemethod:@TypeChecked(extensions='groovy.typecheckers.NullChecker') class UserService { @Nullable static String findUser() { null } static void main(String[] args) { findUser().toUpperCase() // may return null } }We would see the following error at compile-time:
[Static type checking] - Potential null dereference: 'findUser()' may return null
4.5. @MonotonicNonNull and @Lazy
The @MonotonicNonNull annotation is useful for lazy initialization patterns.
A field annotated with @MonotonicNonNull starts as null but once assigned
a non-null value, it must remain non-null. The checker treats such fields as
nullable (requiring null checks before use) but prevents re-assignment to null
after initialization:
@TypeChecked(extensions='groovy.typecheckers.NullChecker')
class LazyService {
@MonotonicNonNull String cachedValue
String getValue() {
if (cachedValue != null) {
return cachedValue.toUpperCase() // ok: null guard
}
cachedValue = 'computed'
return cachedValue.toUpperCase() // ok: just assigned non-null
}
}
Re-assigning null to a @MonotonicNonNull field after it has been set is an error:
void reset() {
value = "hello"
value = null // cannot re-null
}
We would see the following error at compile-time:
[Static type checking] - Cannot assign null to @MonotonicNonNull variable 'value' after non-null assignment
Groovy’s @Lazy annotation is recognized as an implicit @MonotonicNonNull. A @Lazy field
starts as null internally but once its generated getter initializes it, it stays non-null —
exactly the monotonic non-null contract. Since @Lazy generates a getter that handles
initialization automatically, property access to a @Lazy field is always safe and will not
trigger null dereference warnings. The monotonic guarantee means the checker will also prevent
re-assignment to null on the backing field after initialization.
4.6. Integration with @NullCheck
Groovy’s @NullCheck annotation generates runtime null checks for method parameters.
When the null checkers see @NullCheck on a method or class, they treat all non-primitive
parameters as effectively @NonNull, catching violations at compile time before they would
fail at runtime:
@NullCheck
@TypeChecked(extensions='groovy.typecheckers.NullChecker')
class Greeter {
static String greet(String name) {
"Hello, $name!"
}
static void main(String[] args) {
greet(null) // caught at compile time
}
}
We would see the following error at compile-time:
[Static type checking] - Cannot pass null to @NonNull parameter 'name' of 'greet'
Parameters explicitly annotated @Nullable override the @NullCheck behavior
for the purpose of the null checkers (though @NullCheck will still generate
a runtime check for those parameters).
4.7. Integration with @NonNullByDefault
Class-level @NonNullByDefault (from SpotBugs, Eclipse JDT, or your own definition) makes
all parameters, return types, and fields in a class effectively @NonNull. Members explicitly
annotated @Nullable override this default. Similarly, @ParametersAreNonnullByDefault
(JSR-305) applies only to parameters.
@NonNullByDefault
@TypeChecked(extensions='groovy.typecheckers.NullChecker')
class UserService {
String name
static String greet(String name) {
"Hello, $name!"
}
static void main(String[] args) {
greet(null) // compile error: params are @NonNull
}
}
We would see the following error at compile-time:
[Static type checking] - Cannot pass null to @NonNull parameter 'name' of 'greet'
4.8. Integration with @Requires and @Ensures
When groovy-contracts is on the classpath, NullChecker recognises null-safety
facts expressed through Design-by-Contract annotations. Top-level x != null
conjuncts inside a @Requires closure mark the corresponding parameter as
effectively @NonNull; a result != null conjunct inside an @Ensures closure
marks the method return the same way. Both orderings (x != null and
null != x) are recognised. Disjunctions (||) are deliberately not used for
inference, since they do not establish non-nullness of any individual operand.
@TypeChecked(extensions='groovy.typecheckers.NullChecker')
class Greeter {
@Requires({ name != null })
static String greet(name) { "Hello, $name!" }
static void main(String[] args) {
greet(null) // caught at compile time
}
}
We would see the following error at compile-time:
[Static type checking] - Cannot pass null to @NonNull parameter 'name' of 'greet'
The same applies on the return side with @Ensures:
@TypeChecked(extensions='groovy.typecheckers.NullChecker')
class Greeter {
@Ensures({ result != null })
String greet() {
return null // caught at compile time
}
}
We would see the following error at compile-time:
[Static type checking] - Cannot return null from @NonNull method 'greet'
This coexists with explicit @NonNull annotations: a method marked @NonNull
that also carries any @Ensures postcondition, or that lives in a class with
an @Invariant class invariant, still has its literal return null statements
flagged. Without this coordination, the groovy-contracts AST transform would
rewrite the return statement before the type-checking pass runs, hiding the
literal from the checker. The runtime postcondition continues to guard non-literal
cases.
4.9. Flow-sensitive mode with strict option
NullChecker only flags issues involving annotated code. NullChecker(strict: true) adds
flow-sensitive tracking, detecting potential null dereferences in unannotated code
by tracking nullability through variable assignments and control flow:
With NullChecker(strict: true), the following code would be flagged:
def x = null
x.toString() // x may be null
We would see the following error at compile-time:
[Static type checking] - Potential null dereference: 'x' may be null
Reassigning a non-null value clears the nullable state:
@TypeChecked(extensions='groovy.typecheckers.NullChecker(strict: true)')
static main(args) {
def x = null
x = 'hello'
assert x.toString() == 'hello' // ok: reassigned non-null
}
The complete list of errors detected include:
-
assigning
nullto a@NonNullfield or variable (including implicit@NonNullfrom@NonNullByDefault) -
passing
nullor a@Nullablevalue to a@NonNullparameter (including implicit@NonNullfrom@NullCheck,@NonNullByDefault, or@ParametersAreNonnullByDefault) -
returning
nullor a@Nullablevalue from a@NonNullmethod (including implicit@NonNullfrom@NonNullByDefault) -
dereferencing a
@Nullablevariable without a null check or safe navigation -
dereferencing the return value of a
@Nullablemethod without a null check -
re-assigning
nullto a@MonotonicNonNullor@Lazyfield after initialization -
dereferencing a variable known to be null through flow analysis (
strictmode only)
For complementary null-related checks such as detecting broken null-check logic,
unnecessary null guards before instanceof, or Boolean methods returning null,
consider using CodeNarc's null-related rules alongside these type checkers.
|
5. Checking @Modifies Frame Conditions (Incubating)
The ModifiesChecker verifies that method bodies comply with their @Modifies frame
condition declarations from the groovy-contracts module. It checks that methods only
modify the fields and parameters they declare, helping both humans and AI reason about
what a method changes — and crucially, what it does not.
Frame conditions are a well-established concept in formal verification. Dafny’s modifies
clause and JML’s assignable clause serve the same purpose — declaring what a method may
change so that everything else is known to be preserved. Groovy’s @Modifies brings this
idea to the JDK ecosystem in a pragmatic, opt-in form. For projects already using JetBrains
annotations, the checker also recognises @Contract(mutates = "…") which expresses
similar semantics.
This checker is opt-in and only activates for methods annotated with @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 // OK: quantity is declared
reserved = 0 // OK: reserved is declared
}
}
5.1. What the checker verifies
5.1.1. Direct field assignments
Assignments to fields (via this.field = …, field = …, field++, field += …)
must target fields listed in @Modifies. Local variable assignments are always allowed.
@Modifies({ this.count })
void increment() {
count++ // OK: count is declared
def temp = count // OK: local variable
}
@Modifies({ this.count })
void broken() {
other = 5 // ERROR: 'other' not declared in @Modifies
}
5.1.2. Method calls on this
When a method calls another method on this, the checker verifies compatibility:
-
If the callee has
@Modifies, its frame must be a subset of the caller’s frame. -
If the callee is annotated
@Pure, the call is always safe. -
If the callee’s name is in a known-safe list (e.g.,
toString,hashCode), the call is allowed. -
Otherwise, a warning is produced.
@Modifies({ [this.items, this.count] })
void addItem(String item) {
doAdd(item) // OK if doAdd has compatible @Modifies
def n = currentCount() // OK if currentCount() is @Pure
}
5.1.3. Method calls on parameters and variables
When a method calls a method on a variable or parameter:
-
If the receiver is in the
@Modifiesset, any call is allowed (the contract declares the caller may modify it). -
If the receiver is not in the
@Modifiesset, the checker determines whether the call is non-mutating using a layered approach:-
Immutable receiver type: calls on
String,Integer,BigDecimal,LocalDate, etc. are always safe. -
@Puremethod: if the called method is annotated@Pure, it is safe. -
Known-safe method name: a curated whitelist of non-mutating method names including
size,get,contains,isEmpty,toString,iterator,stream,toList, etc. -
Unknown: a warning is produced suggesting the method may modify the receiver.
-
@Modifies({ this.count })
void countItems(List items) {
count = items.size() // OK: size() is known-safe
count = items.toString().length() // OK: String is immutable
}
@Modifies({ list1 })
void process(List list1, List list2) {
list1.add('x') // OK: list1 is in @Modifies
list2.size() // OK: size() is known-safe
list2.clear() // WARNING: clear() may modify list2
}
5.2. Supported annotations from other libraries
The ModifiesChecker recognises purity and frame condition annotations from multiple
libraries by simple name:
| Annotation | Library | Semantics |
|---|---|---|
|
Groovy, Checker Framework, Xtext |
Implies |
|
Checker Framework |
Implies |
|
JetBrains Annotations |
Implies |
|
JetBrains Annotations |
Parsed as a frame condition on callees (see below) |
5.2.1. @Contract(mutates) support
When a callee has @Contract(mutates = "…"), the checker parses the mutates string
to determine what the callee modifies:
-
mutates = ""— callee mutates nothing (equivalent to@Pure) -
mutates = "this"— callee mutates its receiver -
mutates = "param1"— callee mutates its first parameter -
mutates = "this,param1"— callee mutates both
The checker maps param1, param2, etc. to the actual arguments at the call site
and warns if a mutated argument is not in the caller’s @Modifies set:
@Modifies({ this.items })
void process() {
addToList(items, 'x') // OK: items is in @Modifies
addToList(other, 'x') // WARNING: other is not in @Modifies
}
@Contract(mutates = "param1")
static void addToList(List list, String item) { list.add(item) }
5.3. Interaction with other contract annotations
The ModifiesChecker works alongside the other design-by-contract annotations to provide
a complete specification for modular reasoning:
-
@Requiresdeclares what must be true before a call -
@Ensuresdeclares what is guaranteed after a call -
@Modifiesdeclares what may change (and implicitly, what does not) -
@Puredeclares a method has no side effects
Together, these annotations allow analysis of method call sequences without reading method bodies — each method becomes a self-contained specification.
6. Checking @Pure Purity (Incubating)
The PurityChecker verifies that @Pure methods have no side effects. By default, strict
purity is enforced. The allows option declares which effect categories are tolerated.
Languages like Haskell enforce purity through the type system — all side effects must be expressed via IO and State monads, making impure code structurally impossible. Frege (a Haskell dialect for the JVM) takes the same approach. This is powerful but requires incorporating these patterns into your application’s type hierarchies, which can be impractical when integrating with JDK libraries that don’t follow these conventions.
Groovy’s PurityChecker provides an opt-in way to achieve similar levels of reasoning about
method purity while staying pragmatic about the JDK ecosystem. The allows option
acknowledges that real-world "pure" methods sometimes log or emit metrics — effects that
don’t affect program correctness but would violate strict Haskell-style purity.
6.1. Basic usage
// Strict: no side effects at all
@TypeChecked(extensions = 'groovy.typecheckers.PurityChecker')
class Calculator {
@Pure
int add(int a, int b) { a + b } // OK: no effects
@Pure
int broken() { count++; return count } // ERROR: field mutation
}
6.2. Effect categories
The allows option accepts a |-separated list of effect categories:
@TypeChecked(extensions = 'groovy.typecheckers.PurityChecker(allows: "LOGGING|METRICS")')
| Category | What it allows | Examples |
|---|---|---|
|
Calls to logging frameworks and |
|
|
Calls to metrics instruments |
Micrometer counters/timers, OpenTelemetry metrics |
|
File, network, database, and console I/O |
|
|
Time-dependent, random, and environment-dependent calls |
|
6.3. Supported annotations
The checker recognises purity annotations from multiple libraries by simple name:
| Annotation | Library | Semantics |
|---|---|---|
|
Groovy, Checker Framework, Xtext |
Strict purity (side-effect-free + deterministic) |
|
Checker Framework |
No mutations, but non-determinism is implicitly allowed |
|
JetBrains Annotations |
Strict purity (works with CLASS retention) |
|
Groovy |
Effectively pure (checked for side effects) |
Annotations are matched by simple name, so @Pure from any package is recognised. The
@Contract annotation is checked for its pure attribute being true.
6.4. How calls are classified
The checker uses a layered approach to determine if a call is pure:
-
Callee has
@Pure,@SideEffectFree, or@Contract(pure = true)— the call is pure -
Callee has
@Memoized— the call is effectively pure -
Receiver type is immutable (String, Integer, BigDecimal, etc.) — all calls are pure
-
Method is in the known-pure list (
size,get,contains,isEmpty,toString, etc.) — pure -
Call matches an effect category — error unless that category is in
allows -
None of the above — warning: purity cannot be verified
6.5. Reasoning guarantees
The allows declaration is machine-readable, enabling both humans and AI to derive
precise guarantees about @Pure methods:
| Property | strict | +LOGGING | +METRICS | +IO | +NONDETERMINISM |
|---|---|---|---|---|---|
No field mutations |
Yes |
Yes |
Yes |
Yes |
Yes |
Can carry forward state across call |
Yes |
Yes |
Yes |
Yes |
Yes |
Deterministic (same inputs → same output) |
Yes |
Yes |
Yes |
Yes |
No |
Can cache/memoize result |
Yes |
Yes |
Yes |
Yes |
No |
Can reorder call freely |
Yes |
Yes |
Yes |
No |
Yes |
Can eliminate dead call |
Yes |
No |
No |
No |
Yes |
No observable output |
Yes |
No |
No |
No |
Yes |
Each column represents a different allows configuration. For example, a @Pure method
under PurityChecker(allows: "LOGGING") is guaranteed to have no field mutations, be
deterministic, and be safely reorderable — but may produce log output, so dead call
elimination would lose that output.
6.6. Examples
Strict purity (default):
@TypeChecked(extensions = 'groovy.typecheckers.PurityChecker')
class MathUtils {
@Pure
int square(int x) { x * x } // OK
@Pure
long timestamp() { System.nanoTime() } // ERROR: non-deterministic
@Pure
int logged(int x) { println("x=$x"); x * 2 } // ERROR: logging
}
Allowing logging:
@TypeChecked(extensions = 'groovy.typecheckers.PurityChecker(allows: "LOGGING")')
class Service {
@Pure
int compute(int x) {
println("computing $x") // OK: logging allowed
return x * x
}
@Pure
long getTime() { System.nanoTime() } // ERROR: non-deterministic (not allowed)
}
Allowing logging and non-determinism:
@TypeChecked(extensions = 'groovy.typecheckers.PurityChecker(allows: "LOGGING|NONDETERMINISM")')
class Diagnostics {
@Pure
String snapshot(Map state) {
println("snapshot at ${System.nanoTime()}") // OK: both allowed
return state.toString()
}
}
6.7. What the checker detects
The checker flags:
-
Direct field assignments on
this(this.x = …,x++wherexis a field) -
Property assignments on parameters and local variables (
param.x = 1) -
Calls to methods not known to be pure
-
Calls matching effect categories (logging, metrics, I/O, non-determinism) unless allowed
-
Construction of non-deterministic or I/O types
The checker does not currently detect:
-
Mutation through aliases (
def ref = this.list; ref.clear()) -
Deep heap mutation (
this.items.get(0).setName("x")) -
Mutation via chained property access (
a.b.c = 1) -
Side effects within closures passed to higher-order methods
These limitations are acceptable for an opt-in checker — the common cases are caught, and unverifiable calls produce warnings.
6.8. Relationship to @Modifies and ModifiesChecker
@Pure implies @Modifies({}) — a method with no field mutations. The ModifiesChecker
recognises @Pure and verifies the no-mutation guarantee. The PurityChecker goes further,
verifying additional purity properties (no impure calls, no I/O, etc.).
Both checkers can be used independently or together:
// Just frame conditions
@TypeChecked(extensions = 'groovy.typecheckers.ModifiesChecker')
// Just purity
@TypeChecked(extensions = 'groovy.typecheckers.PurityChecker')
// Both -- ModifiesChecker handles @Modifies, PurityChecker handles @Pure
@TypeChecked(extensions = ['groovy.typecheckers.ModifiesChecker',
'groovy.typecheckers.PurityChecker'])