Compile-time null safety for Groovy™

Author:  Paul King
PMC Member

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:

  • null is an object. null.getClass() returns org.codehaus.groovy.runtime.NullObject, Groovy’s null singleton. That is why null.toString() yields 'null' rather than throwing, and why null.equals(null) is true.

  • Groovy truth. null is falsy, so if (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?.city yields null rather 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 assignment name ?= 'Anonymous' does it in place.

  • Safe indexing, ?[]list?[0] and map?[key] extend the same courtesy to subscripting.

  • Spread-dot on absence. list*.name returns null for a null list rather than throwing.

  • GDK methods tolerate null. sort orders nulls first, groupBy will happily key on null, and string interpolation renders it as null instead of failing.

  • @NullCheck generates 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

NullChecker

Checks code annotated with @Nullable / @NonNull (or equivalents). Unannotated code passes without error.

Existing projects adopting null safety incrementally.

NullChecker(strict: true)

Everything NullChecker does, plus flow-sensitive tracking — even unannotated def x = null; x.toString() is flagged.

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

JSpecify

@Nullable, @NullMarked, @NullUnmarked

JSR-305 (javax.annotation)

@Nullable, @Nonnull, @ParametersAreNonnullByDefault

JetBrains

@Nullable, @NotNull

SpotBugs / FindBugs

@Nullable, @NonNull, @NonNullByDefault

Checker Framework

@Nullable, @NonNull, @MonotonicNonNull

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 typeString and String? are different types

a ? on every type that may be absent

Java + JSpecify / Checker Framework

a declaration on the element, read by a tool

@Nullable / @NonNull annotations

Groovy NullChecker(strict: true)

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, @Lazy fields 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