Compile-time null safety for Groovy™
Published: 2026-04-02 10:00AM (Last updated: 2026-08-12 10:00AM)
Introduction
Groovy 6 ships compile-time null-safety analysis as a type-checking
extension
(GROOVY-11894).
Inspired by the
Checker Framework,
JSpecify, and similar tools in Kotlin and C,
NullChecker catches null dereferences, unsafe assignments, and
missing null checks before your code ever runs.
That is the second half of Groovy’s null story, though, and it is worth starting with the first.
The extension plugs into Groovy’s existing @TypeChecked
infrastructure — no new compiler plugins, no separate build step,
just an annotation on the classes or methods you want checked.
You can also use a
compiler configuration script
to apply it across your entire codebase without needing
to explicitly add the @TypeChecked annotations.
This post walks through a series of bite-sized examples showing what the day-to-day experience would feel like. To make things concrete, the examples follow a running theme: building the backend for The Groovy Shelf, a fictitious online bookshop where customers browse, reserve, and review books.
First, the dynamic story
Long before any compile-time checking, Groovy gave dynamic code a set of tools for dealing with absence. If you write in a dynamic style — or you are reading Groovy that does — these are the idioms doing the work, and they cost nothing:
-
nullis an object.null.getClass()returnsorg.codehaus.groovy.runtime.NullObject, Groovy’s null singleton. That is whynull.toString()yields'null'rather than throwing, and whynull.equals(null)istrue. -
Groovy truth.
nullis falsy, soif (name)is both a null check and an emptiness check. Empty strings, empty collections and zero are falsy too — usually what you meant, occasionally not, so it pays to know which you are relying on. -
Safe navigation,
?.—user?.address?.cityyieldsnullrather than throwing the moment any link in the chain is absent. Chains short-circuit, so one?.protects everything downstream of it. -
Elvis,
?:—name ?: 'Anonymous'supplies a default for a falsy value, and the elvis assignmentname ?= 'Anonymous'does it in place. -
Safe indexing,
?[]—list?[0]andmap?[key]extend the same courtesy to subscripting. -
Spread-dot on absence.
list*.namereturnsnullfor a null list rather than throwing. -
GDK methods tolerate null.
sortorders nulls first,groupBywill happily key onnull, and string interpolation renders it asnullinstead of failing. -
@NullCheckgenerates explicit runtime guards on parameters when you want fail-fast behaviour at the boundary rather than quiet propagation.
Between them these cover a great deal of everyday code, and they are the reason Groovy programs tend to bend rather than break around missing values. What they cannot tell you is whether a value can be null in the first place — every one of them is a decision you make at the point of use, and forgetting one is invisible until runtime. That is the gap the rest of this post is about.
Two levels of strictness
NullChecker has two levels. Pick the one that suits your code:
| Checker | Behaviour | Best for |
|---|---|---|
|
Checks code annotated with |
Existing projects adopting null safety incrementally. |
|
Everything |
New code or modules where you want full coverage. |
Both are enabled through @TypeChecked:
@TypeChecked(extensions = 'groovy.typecheckers.NullChecker')
class RelaxedCode { /* … */ }
@TypeChecked(extensions = 'groovy.typecheckers.NullChecker(strict: true)')
class StrictCode { /* … */ }
For code bases with a mix of strictness requirements, apply the appropriate checker per class or per method.
The problem: the billion-dollar mistake at runtime
Tony Hoare famously called null references his "billion-dollar mistake". In Groovy and Java, nothing stops you from writing:
String name = null
println name.toUpperCase() // NullPointerException at runtime
The code compiles, the tests might even pass if they don’t hit that
path, and the exception surfaces in production. NullChecker moves
this class of error to compile time.
Example 1: looking up a book — @Nullable parameters
A customer searches for a book by title. The title might come from
a form field that wasn’t filled in, so the parameter is @Nullable:
@TypeChecked(extensions = 'groovy.typecheckers.NullChecker')
Book findBook(@Nullable String title) {
if (title != null) {
return catalog.search(title.trim()) // ok: inside null guard
}
return Book.FEATURED
}
The checker verifies that title is only dereferenced inside the
null guard. Remove the if and you get a compile-time error:
[Static type checking] - Potential null dereference: 'title' is @Nullable
No runtime surprise — the mistake is caught before the code ships.
Example 2: greeting a customer — catching null arguments
When a customer places an order, we greet them by name.
The name is @NonNull — it must always be provided:
@TypeChecked(extensions = 'groovy.typecheckers.NullChecker')
class OrderService {
static String greet(@NonNull String name) {
"Welcome back, $name!"
}
static void main(String[] args) {
greet(null) // compile error
}
}
[Static type checking] - Cannot pass null to @NonNull parameter 'name' of 'greet'
The checker also catches returning null from a @NonNull method
and assigning null to a @NonNull field — the same principle
applied consistently across assignments, parameters, and returns.
Example 3: safe access patterns — the checker is smart
Groovy already offers the safe-navigation operator (?.) for
working with nullable values. The NullChecker understands it,
along with several other patterns:
Safe navigation:
@TypeChecked(extensions = 'groovy.typecheckers.NullChecker')
String displayTitle(@Nullable String title) {
title?.toUpperCase() // ok: safe navigation
}
Null guards:
@TypeChecked(extensions = 'groovy.typecheckers.NullChecker')
String formatTitle(@Nullable String title) {
if (title != null) {
return title.toUpperCase() // ok: null guard
}
return 'Untitled'
}
Early exit:
@TypeChecked(extensions = 'groovy.typecheckers.NullChecker')
String formatTitle(@Nullable String title) {
if (title == null) return 'Untitled' // early exit
title.toUpperCase() // ok: title is non-null here
}
Elvis assignment:
@TypeChecked(extensions = 'groovy.typecheckers.NullChecker(strict: true)')
static main(args) {
String title = null
title ?= 'Untitled'
title.toUpperCase() // ok: elvis cleared nullable state
}
The checker performs the same kind of narrowing that a human reader
does: once you’ve ruled out null — whether by an if, an early
return, a throw, or an elvis assignment — the variable is safe.
Since the first version of this post, guard recognition has been broadened considerably (GROOVY-12208). All of these now narrow:
if (s) { s.length() } // Groovy truth
if (Objects.nonNull(s)) { s.length() } // Objects.nonNull / isNull
assert s != null; s.length() // assert, plain or comparing
if (o instanceof String) { o.length() } // instanceof, and !instanceof
if (s != null && s.length() > 0) { … } // short-circuit conjunction
if (s == null || s.isEmpty()) return -1 // disjunction, as an early exit
while (s != null && n < 1) { n = s.length() } // and in while loops, not just if
A companion change
(GROOVY-12209)
taught the checker to reason about the results of the nullable
operators rather than just the variables feeding them. A ternary is
nullable only if a branch can actually be null once the condition’s
own guard facts are applied, so s != null ? s : 'default' is not
nullable. An elvis expression is nullable only if its fallback is —
which makes s ?: 'default' a recognised way to hand a @Nullable
value to something that demands non-null:
static String needsNonNull(@NonNull String s) { s.toUpperCase() }
static String pass(@Nullable String s) {
needsNonNull(s ?: 'fallback') // ok: elvis cannot yield null
}
Most recently, the checker learned that some method calls are
guards too
(GROOVY-12250).
A validator that throws on null — Objects.requireNonNull, or a
Guava-style checkNotNull — guarantees its argument is non-null
whenever it completes normally, so the code after it is safe:
@TypeChecked(extensions = 'groovy.typecheckers.NullChecker')
int titleWidth(@Nullable String title) {
Objects.requireNonNull(title, 'title must be supplied')
title.length() // ok: requireNonNull throws on null
}
The same reasoning covers test code, which previously collected
false positives for values it had just asserted were present.
assertNotNull(title) now narrows title — with the message
parameter in either position, so JUnit 4’s (message, actual) and
JUnit 5 / TestNG’s (actual, message) conventions both work — and
fluent chains such as assertThat(title).isNotNull() narrow too,
even through intermediate calls like describedAs(…). As with
annotations, matching is by simple name, so any library following
the standard naming conventions works without configuration.
Example 4: non-null by default — less annotation noise
Annotating every parameter and field gets tedious. Class-level
defaults let you flip the polarity: everything is @NonNull unless
you say otherwise with @Nullable:
@NonNullByDefault
@TypeChecked(extensions = 'groovy.typecheckers.NullChecker')
class BookService {
String name // implicitly @NonNull
static String formatISBN(String isbn) { // isbn is implicitly @NonNull
"ISBN: $isbn"
}
static void main(String[] args) {
formatISBN(null) // compile error
}
}
[Static type checking] - Cannot pass null to @NonNull parameter 'isbn' of 'formatISBN'
The checker recognises several class-level annotations for this:
-
@NonNullByDefault(SpotBugs, Eclipse JDT) -
@NullMarked(JSpecify) -
@ParametersAreNonnullByDefault(JSR-305 — parameters only)
JSpecify’s @NullUnmarked can be applied to a nested class to opt
out of a surrounding @NullMarked scope.
Integration with @NullCheck
Groovy’s existing @NullCheck annotation generates runtime null
checks for method parameters. The NullChecker complements this by
catching violations at compile time:
@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
}
}
With @NullCheck on the class, the checker treats all non-primitive
parameters as effectively @NonNull. You still get the runtime
guard as a safety net, but now you also get a compile-time error
alerting you before the code ever executes. Parameters explicitly
annotated @Nullable override this behaviour.
Example 5: lazy initialisation — @MonotonicNonNull and @Lazy
Some fields start as null but, once initialised, should never be
null again. The @MonotonicNonNull annotation expresses this
"write once, then non-null forever" contract:
@TypeChecked(extensions = 'groovy.typecheckers.NullChecker')
class RecommendationEngine {
@MonotonicNonNull String cachedResult
String getRecommendation() {
if (cachedResult != null) {
return cachedResult.toUpperCase() // ok: null guard
}
cachedResult = 'Groovy in Action'
return cachedResult.toUpperCase() // ok: just assigned non-null
}
}
The checker treats @MonotonicNonNull fields as nullable (requiring
a null guard before use) but prevents re-assignment to null after
initialisation:
void reset() {
cachedResult = 'something'
cachedResult = null // compile error
}
[Static type checking] - Cannot assign null to @MonotonicNonNull variable 'cachedResult' after non-null assignment
Groovy’s @Lazy annotation is implicitly treated as
@MonotonicNonNull. Since @Lazy generates a getter that handles
initialisation automatically, property access through the getter is
always safe and won’t trigger null dereference warnings.
Example 6: going strict — flow-sensitive analysis
The standard NullChecker only flags issues involving annotated
code — unannotated code passes silently. The strict mode
goes further, tracking nullability through assignments and control
flow even without annotations:
@TypeChecked(extensions = 'groovy.typecheckers.NullChecker(strict: true)')
static main(args) {
def x = null
x.toString() // compile error
}
[Static type checking] - Potential null dereference: 'x' may be null
The checker tracks nullability through ternary expressions, elvis expressions, method return values, and reassignments. Assigning 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
}
This is ideal for new modules where you want comprehensive null coverage from the start, without annotating every declaration.
Strict mode also checks field initialisation
Declaring a field @NonNull promises it never holds null — but a
field that is never assigned at all holds null from construction
onwards, and earlier versions of the checker let that pass silently.
Strict mode now verifies definite initialisation
(GROOVY-12251):
every explicitly-annotated @NonNull instance field must be
assigned at its declaration, in an instance initialiser block, or by
every declared constructor (a constructor delegating via this(…)
relies on its delegate):
@TypeChecked(extensions = 'groovy.typecheckers.NullChecker(strict: true)')
class Library {
@NonNull String catalog // never assigned
Library(String catalog) { } // oops — forgot this.catalog = catalog
}
[Static type checking] - @NonNull field 'catalog' is not initialized by all constructors
The check is deliberately confined to strict mode so that
named-argument bean construction — new Book(title: 'Groovy in
Action') — keeps working unflagged in relaxed code. Fields annotated
@MonotonicNonNull or @Lazy are excluded, since as Example 5
showed, they are supposed to start out null; so are primitives and
static fields.
Annotation compatibility
The checker matches annotations by simple name, not by fully-qualified class name. This means it works with annotations from any library:
| Library | Annotations |
|---|---|
|
|
JSR-305 ( |
|
JetBrains |
|
SpotBugs / FindBugs |
|
Checker Framework |
|
JSpecify on compiled dependencies
Simple-name matching handles annotations in your own source. Two
Groovy 6 changes extend the same reach to libraries you did not
compile yourself:
GROOVY-12206
ingests type-use annotations when reading compiled classes, and
GROOVY-12207
consults a dependency’s package-info.class during resolution.
Together they mean a JSpecify-annotated jar works out of the box. Given this Java library, compiled separately and available only as class files:
// package-info.java
@org.jspecify.annotations.NullMarked
package com.acme;
// Repo.java
public class Repo {
public @Nullable String findName(String id) { … }
public String greeting() { … } // @NullMarked ==> implicitly non-null
}
a Groovy consumer gets both halves of the contract with no annotations of its own:
@TypeChecked(extensions = 'groovy.typecheckers.NullChecker')
class Consumer {
static int bad(Repo r) { r.findName('u42').length() } // compile error
static int good(Repo r) { r.greeting().length() } // fine
}
[Static type checking] - Potential null dereference: 'findName()' may return null
That is the producer/consumer split working across a jar boundary: the
library author declared the contract once, and every downstream Groovy
file compiled under NullChecker collects the benefit. JSpecify is
bundled with the Groovy 6 distribution, so there is nothing to add.
Nullable type arguments
Type-use annotations can also sit inside a generic type:
List<@Nullable String> is a list whose elements may be null even
though the list itself never is. The changes above made those
annotations visible to the compiler, but the checker never looked
inside the type arguments. Now it does
(GROOVY-12252):
element access on such a collection is nullable, however you spell
it —
@TypeChecked(extensions = 'groovy.typecheckers.NullChecker')
class Catalog {
static int firstTitleLength(List<@Nullable String> titles) {
titles.get(0).length() // flagged — and so are
} // titles[0], titles.head(), …
}
[Static type checking] - Potential null dereference: 'get()' may return null
Subscript access (xs[0]), the GDK’s head() and first(), and map
lookups on a Map<String, @Nullable Integer> — both m.get(key) and
m[key] — are all recognised. The nullness flows through class- and
method-level type variables, and, as with the rest of the JSpecify
support, works for annotations read from compiled dependencies.
Guarding the element narrows it exactly as for any other nullable
value.
If you prefer not to add an external dependency, you can define your own minimal annotations — the checker only cares about the simple name:
@Target([ElementType.PARAMETER, ElementType.METHOD, ElementType.FIELD])
@Retention(RetentionPolicy.CLASS)
@interface Nullable {}
@Target([ElementType.PARAMETER, ElementType.METHOD, ElementType.FIELD])
@Retention(RetentionPolicy.CLASS)
@interface NonNull {}
How this compares
The three mainstream approaches to null safety differ less in what they catch than in where the information about nullness is kept:
| Approach | Nullness is… | What you write |
|---|---|---|
Kotlin |
part of the type — |
a |
Java + JSpecify / Checker Framework |
a declaration on the element, read by a tool |
|
Groovy |
inferred from flow through the method body |
nothing |
In strict mode the checker works out that def x = null makes x
nullable, that x = 'hello' clears it, and that a guard narrows it —
without a ? in the type and without an annotation. That is a real
ergonomic difference: the same analysis a careful reader does, applied
mechanically, to code you have not modified.
It is emphatically not a claim to beat Kotlin’s model, and on the axis that matters most the trade runs the other way:
-
Kotlin’s guarantee is total and always on. Nullness carried in the type survives every call boundary, every generic instantiation and every module edge, and the compiler will not let you opt out. Groovy’s flow analysis is local to the code being checked, and you choose where to switch it on.
-
Past your own code, declarations still carry the information. Flow inference knows about values it can watch being created and narrowed. What a library returns is a fact only that library can state — which is exactly why the JSpecify support above matters.
-
Kotlin pays the cost once, at the type. Groovy pays it per analysis scope. Whether that is a saving depends on how much of your codebase you turn the checker on for.
Against the Java tooling in that middle row — the Checker Framework, Error Prone, or IDE-specific inspections in IntelliJ and Eclipse, each with its own setup, annotation flavour and build integration — Groovy’s checker has some practical advantages:
-
Zero setup. It is a type-checking extension: add one annotation, or register it once in a compiler configuration script, and you are done. No annotation processor configuration, no extra compiler flags, no Gradle plugin.
-
Any annotation library. Simple-name matching means JSpecify, JSR-305, JetBrains, SpotBugs, Checker Framework or your own hand-rolled marker all work, interchangeably, including from compiled dependencies.
-
It understands Groovy idioms. Safe navigation, elvis assignment,
@Lazyfields and Groovy truth are all recognised as narrowing forms. A Java-only tool cannot help you here, because it has never heard of them. -
Incremental by construction. Annotation-only checking on existing code, flow-sensitive mode on new modules — the two levels mean there is no all-or-nothing migration to schedule.
-
It complements
@NullCheck. Catch violations at compile time and keep the generated runtime guard as a safety net.
The honest summary is that these are different points on an adoption-cost-versus-strength curve rather than better and worse. If you are starting fresh and want the guarantee everywhere, a type system that encodes nullness is the stronger tool. If you have an existing Groovy codebase and want most of the benefit without touching a single declaration, inference on the variable gets you a long way for nothing.
The full picture
The examples above cover the most common scenarios. NullChecker
also detects nullable method return value dereferences, @Nullable
values flowing into @NonNull parameters through variables, nullable
propagation in ternary and elvis expressions, and JSpecify’s
@NullMarked / @NullUnmarked scoping. The reference documentation
is in the
type checkers user guide.
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.
We’d love your feedback
NullChecker shipped in Groovy 6
(GROOVY-11894),
and has been refined since on the strength of real usage — broader
guard recognition, nullable-expression tracking, JSpecify support
reaching into compiled dependencies, narrowing through
requireNonNull-style validators and test assertions,
definite-initialisation checking for @NonNull fields, and
@Nullable type arguments in generics all landed after the first
version of this post.
Null safety is a foundational concern and the edges are where the design gets tested, so if the checker flags something it shouldn’t, or stays quiet where you expected a diagnostic, please raise an issue — a small reproducer is worth a great deal.
Conclusion
Through our Groovy Shelf bookshop examples we’ve seen how
NullChecker catches null dereferences, unsafe assignments,
and missing null checks at compile time — from looking up books
with nullable titles, to enforcing non-null parameters, recognising
Groovy’s safe-navigation idioms, applying class-level defaults with
@NonNullByDefault, handling lazy initialisation with
@MonotonicNonNull, and tracking nullability through control flow
with NullChecker(strict: true). The setup is minimal, the annotation
compatibility is broad, and the two-tier strictness model lets you
adopt null safety at your own pace.
References
-
GROOVY-11894 — NullChecker (PR #2426), plus the follow-ups: GROOVY-12206 and GROOVY-12207 (JSpecify on compiled dependencies), GROOVY-12208 (broader guards), GROOVY-12209 (nullable expression tracking), GROOVY-12250 (validator and assertion narrowing), GROOVY-12251 (definite initialisation of
@NonNullfields) and GROOVY-12252 (nullable type arguments) -
Groovy 6 for humans and AI agents — where these declarations fit the wider picture
