GEP-29
|
Abstract
Null-mishandling remains the largest class of JVM bug that today’s type systems let through, and it is disproportionately common in generated and rapidly-composed code. The JVM ecosystem has converged on JSpecify as the standard nullness vocabulary: JSpecify 1.0 shipped in 2024, and Spring Framework 7 ships with JSpecify annotations across its public API.
Groovy 6 answers with the incubating groovy.typecheckers.NullChecker — an annotation-driven
type-checking extension with optional flow-sensitive analysis, null-guard recognition, and a
bridge from groovy-contracts (@Requires/@Ensures conditions inferring non-nullness). That is
a lint with local flow. This GEP proposes the Groovy 7 step: nullness as part of the type
system — a qualifier carried on every inferred type, refined by the flow typing the static type
checker already performs, defined by JSpecify semantics (type-use positions, @NullMarked
scoping, generics), ingested from annotated dependencies, applied to Groovy’s own idioms
(?., ?:, *., Groovy truth), progressively declared by the standard library, and emitted so
Java-side tools see Groovy APIs the same way.
The one-line pitch: Groovy statically checks what Java only annotates — with Groovy’s syntax
(?. and friends) making the fixes as ergonomic as the diagnostics.
Motivation
Where Groovy stands today
The Groovy 6 baseline is further along than commonly assumed:
-
NullChecker(incubating,groovy-typecheckers) checks annotation violations — null to@NonNullvariables/parameters/returns, unguarded@Nullabledereferences,@MonotonicNonNullre-assignment — recognizing annotations by simple name from any package (JSR-305 style, Spring’s legacy annotations, JSpecify names including@NullMarked/@NullUnmarkedat class level). -
A
strictoption adds method-local flow analysis: null-literal tracking through assignments, ternaries and Elvis expressions, variable-to-variable propagation, guard recognition (x != nullcomparisons, early exits), and uninitialized-variable detection. -
groovy-contracts feeds the checker:
@Requires/@Ensuresconditions infer non-nullness for parameters and returns viaStaticTypesMarker.INFERRED_NON_NULL(org.apache.groovy.contracts.ast.visitor.AnnotationClosureVisitor), so the contracts-then-types hardening ladder already interoperates. -
A 6.x pipeline extends this within the extension architecture: GROOVY-12206 (ingest type-use annotations from compiled classes), GROOVY-12207 (read dependency
package-info.class, package-level@NullMarkedscoping), GROOVY-12208 (broader guard vocabulary: Groovy truth,instanceof, conjunctions,Objects.nonNull, asserts), GROOVY-12209 (expression-level nullness: safe-navigation results, nullable arguments, ternary/Elvis over non-literals).
Two 6.x candidates were deliberately deferred to this GEP so their semantics are settled once: a strict JSpecify mode (fully-qualified matching, spec-exact scoping, in-scope non-null defaulting) and a JSpecify conformance-suite harness. They appear below as Phase 1 deliverables.
The structural limits of the extension architecture
NullChecker re-walks method bodies after type checking, with its own small dataflow keyed by
variables. GROOVY-12208/12209 push that model to its useful limit; beyond it lie things a
post-hoc visitor cannot reach:
-
Nullness is not part of the inferred type. Expression results, generic returns (
list.get(0)on aList<@Nullable String>), SAM/closure parameter nullness via target typing, and multi-method overload selection are all invisible to a pass that only tags variables. -
No generics nullness.
List<@Nullable String>vs@Nullable List<String>,T extends @Nullable Object, wildcard rules,String @Nullable []vs@Nullable String[]— JSpecify is a type-use system, and type-use positions require the checker to live where types are constructed and compared, not where statements are visited. -
Duplicated, weaker flow analysis. The static type checker already performs flow typing (
instanceofnarrowing, ternary merging); the extension re-implements a partial copy for nullness. Integration replaces two engines with one. -
No producing side. An extension can consume annotations but cannot make Groovy-compiled APIs carry spec-correct type-use annotations for Java-side checkers, and cannot annotate the GDK.
The ecosystem timing
Pure JSpecify annotations are @Target(TYPE_USE) only: in bytecode they live exclusively in
type-annotation attributes, which Groovy historically discarded — a library annotated with pure
JSpecify was invisible to Groovy tooling even in non-generic positions (the ingestion gap
GROOVY-12206 closes). With Spring Framework 7, Micrometer, and a growing list of libraries
publishing JSpecify metadata, the information Groovy needs is increasingly just sitting in the
jars. Kotlin demonstrated the adoption dynamics: a JVM language that understands nullability
metadata at the type level makes annotated Java APIs materially safer to consume, and its users
then push more libraries to annotate.
There is also a distinctly Groovy angle: Groovy’s syntax already contains the repair kit —
?., ?:, *. with null-elision — so nullness diagnostics in Groovy pair every finding with a
one-character fix, and the checker can equally flag the reverse (?. on a receiver proved
non-null is noise worth a hint). No Java-side tool can offer that pairing. And for AI-assisted
development, machine-checkable nullness turns the most common class of generated-code defect
into a compile-time signal rather than a production incident.
Proposal
Nullness as a type qualifier
Every type the static type checker infers carries a nullness qualifier:
| Qualifier | Meaning |
|---|---|
|
Proved or declared non-null; dereference and passing to non-null positions allowed |
|
May be null; unguarded dereference is a diagnostic |
|
No information (unannotated code outside |
Qualifiers are refined by flow: the same narrowing the checker performs for instanceof
applies to null tests, Groovy-truth guards, safe-navigation, and Elvis — one flow engine,
shared. Method-local results from GROOVY-12208/12209 become the conformance baseline for the
integrated engine.
JSpecify semantics
-
Vocabulary:
org.jspecify.annotations.Nullable,NonNull,NullMarked,NullUnmarked, matched fully-qualified. The lenient simple-name recognition ofNullCheckerremains for the mixed pre-JSpecify ecosystem, as a configuration choice. -
Scoping:
@NullMarked/@NullUnmarkedinnermost-wins along module → package → class → method nesting, per the JSpecify specification. Within a marked scope, unannotated non-primitive type uses default to non-null. -
Type-use positions: nullness attaches where JSpecify puts it — including type arguments, type-variable bounds, wildcards, and array component vs array reference. Groovy’s grammar already accepts annotations in these source positions and Groovy-compiled classes carry them; dependency ingestion is GROOVY-12206/12207.
-
Conformance: an adapter runs the applicable subset of the JSpecify conformance suite in CI, categorized pass / known-gap / bug, published as a support matrix in the documentation. The matrix is the honest statement of which semantics each release implements.
Groovy idiom rules
The distinctive part of the proposal — the rules no Java checker can express:
| Idiom | Rule |
|---|---|
|
Result is |
|
Result nullness is `d’s nullness (the Groovy-truth semantics of Elvis guarantee the left branch contributes only non-null values) |
|
Groovy truth narrows |
|
Propagates: |
|
|
Implicit truth conversions |
|
Producing side
-
Groovy source can declare
@NullMarkedat any JSpecify scope, and the compiler emits type-use annotations into bytecode (and joint-compilation stubs) at spec-correct positions, so NullAway, IntelliJ, the JSpecify reference checker, and Kotlin see Groovy APIs precisely. -
The GDK is annotated progressively: wave 1 covers the highest-traffic DGM surfaces (collections, strings, IO), with the fat-free functional-interface overloads annotated from birth. Annotating the GDK is mechanical but large; it ships in waves with the conformance matrix tracking coverage, and is itself strong dogfooding of the checker.
Adoption model
Strictly opt-in, two independent switches:
-
Consuming: a compiler/
@TypeCheckedconfiguration (or simply the presence of@NullMarkedin the code being compiled) enables nullness checking; severity is configurable with warnings as the default tier and errors opt-in, so large codebases can ratchet. -
Producing: annotating Groovy code is just annotating — no flag required; emission is on whenever annotations are present.
NullChecker remains as the friendly entry point and the home of lenient-mode heuristics; once
the integrated engine lands, its strict mode delegates to core and the extension graduates from
incubating. Nothing about dynamic-only Groovy changes.
Groovy 7.0 deliverables
| Phase | What ships | Notes |
|---|---|---|
1 |
Nullness qualifier representation on inferred types; consumption of ingested metadata (GROOVY-12206/12207); strict JSpecify scoping and defaulting; conformance harness and first support matrix |
The two deferred 6.x items land here, on GEP-settled semantics |
2 |
Unified flow refinement (subsumes the GROOVY-12208/12209 method-local logic at type level); expression nullness everywhere; closure/SAM parameter nullness via target typing; Groovy idiom rules incl. |
The user-visible heart of the feature |
3 |
Generics: type-argument and type-variable nullness, wildcard rules, arrays; overload selection interplay |
The technically hardest phase; conformance matrix gates it honestly |
4 |
GDK annotation wave 1 + emission into bytecode/stubs; documentation ladder (dynamic → |
Producing-side; begins as soon as Phase 1 emission plumbing exists |
A Phase 1 spike — the qualifier representation threaded through inference without breaking the existing STC test suite — should precede GEP acceptance, since representation cost (every inferred-type comparison now carries a qualifier) is the main technical risk.
Excluded and deferred features
| Feature | Status | Rationale |
|---|---|---|
Runtime null-check injection |
Not planned |
|
Kotlin-style nullable type syntax ( |
Deferred |
Interop-first: JSpecify annotations are the ecosystem’s shared vocabulary. Sugar mapping |
Changing dynamic Groovy semantics |
Not planned |
Dynamic code stays |
Constructor initialization checking (partially-initialized |
Deferred |
Valuable but a distinct analysis with its own annotation set; revisit after Phase 3 |
Full |
Deferred |
Remains in `NullChecker’s lenient layer until demand is shown |
Other pluggable type qualifiers (units, tainting) |
Not planned |
Nullness is special-cased by ecosystem convergence; a general qualifier framework is out of scope |
Valhalla null-restricted types alignment |
Watch |
If the JDK’s null-restricted value type work lands during Groovy 7’s lifetime, the qualifier model should map onto it; tracked, not blocked on |
Compatibility and impact
Backwards compatibility
Opt-in by construction: code that neither enables checking nor carries JSpecify annotations compiles bit-for-bit identically. Ingestion (GROOVY-12206/12207) only makes existing metadata reachable. New diagnostics appear only inside opted-in scopes, warnings-first.
Binary compatibility
Emitted type-use annotations are additive classfile metadata; no new call-site shapes, markers, or runtime types. GDK annotation waves change no signatures — only annotations — and are therefore binary-compatible by definition, though they do change what strict Java-side checkers report against Groovy APIs, which is release-noted per wave.
Performance
Two costs to measure from Phase 1 onward: qualifier threading through inference (per-compilation CPU) and lazy retention of ingested type annotations (memory, bounded by the laziness requirements in GROOVY-12206). Both gated by the spike.
Tooling
IntelliJ IDEA maintains its own Groovy inference and its own nullness inspections; alignment should be flagged to JetBrains early, as with every STC-visible change. Stub generation must carry type-use annotations for joint compilation (part of Phase 4 emission work). The conformance matrix doubles as the tooling-facing statement of semantics.
Documentation
One page presenting the hardening ladder end-to-end: dynamic Groovy → @TypeChecked →
groovy-contracts → nullness — with the contracts bridge (already live in Groovy 6) as the worked
example that these layers compound rather than compete.
Alternatives considered
-
Grow
NullCheckerindefinitely instead of integrating. The 6.x pipeline deliberately takes this as far as it goes; the limits section is the evidence it is not a terminal state. Retained as the migration vehicle, rejected as the destination. -
Rely on external tools (NullAway, Checker Framework) over Groovy output. They analyze Java sources or bytecode without Groovy’s AST, see none of the idioms (
?.chains, Elvis, spread, Groovy truth), and cannot check dynamic-to-static boundaries. Useful for Java consumers of emitted annotations — which is exactly why the producing side matters — but not a substitute for checking Groovy itself. -
Introduce nullable types as language syntax first (Kotlin model). Maximal ergonomics but forks the vocabulary from the Java ecosystem at the moment the ecosystem finally standardized; JSpecify-first keeps every annotation meaningful to and from Java, and leaves room for syntax sugar later.
-
Do nothing. Spring 7-era APIs carry machine-readable nullness that Groovy would silently discard; Kotlin reads it, IDEs read it, and Groovy would be the JVM language that ignores the metadata its own users' dependencies publish.
References
-
JSpecify — specification, 1.0 release, and design documents
-
JSpecify conformance test suite — basis for the Phase 1 harness and support matrix
-
Spring Framework null-safety — JSpecify adoption across the Spring 7 API surface
-
NullAway and Checker Framework Nullness Checker — prior art for JSpecify-aware checking on the JVM
-
Kotlin null safety — precedent for type-system nullness with annotation-driven Java interop
-
GROOVY-12206 — ingest type-use annotations when reading compiled classes
-
GROOVY-12207 — consult
package-info.classof dependencies during resolution -
GROOVY-12208 — NullChecker: broaden null-guard recognition
-
GROOVY-12209 — NullChecker: expression-level nullness
-
groovy.typecheckers.NullChecker,org.apache.groovy.typecheckers.CheckingVisitor— the incubating Groovy 6 checker this proposal builds on -
org.apache.groovy.contracts.ast.visitor.AnnotationClosureVisitor— the groovy-contracts bridge inferring non-nullness from@Requires/@EnsuresviaStaticTypesMarker.INFERRED_NON_NULL -
groovy.transform.NullCheck— the existing runtime-hardening transform this proposal composes with -
GEP-27: Compact Closure and Lambda Compilation and GEP-28: Dot Shorthands — companion GEPs in the same series