GEP-29


Metadata
Number

GEP-29

Title

Stricter JSpecify Nullness for the Static Type Checker

Version

1

Type

Feature

Status

Draft

Target

Groovy 7.0

Comment

Make nullness a first-class qualifier in Groovy’s static type checking, with JSpecify (org.jspecify.annotations) as the annotation vocabulary and semantics reference. Groovy 6’s incubating NullChecker extension (plus its 6.x pipeline: type-annotation ingestion, package-info reading, broader guard recognition, expression-level nullness) is the migration vehicle and proving ground; this GEP covers what the extension architecture cannot reach — nullness in the inference lattice, generics and type-variable nullness, overload interplay, standard library annotation, and emission for Java-side checkers. Opt-in per module; unannotated and non-opted code compiles identically.

Leader

Paul King

Created

2026-07-28

Last modification

2026-07-28

Note
WARNING: Material on this page is still under development! This proposal targets Groovy 7.0 and is an early outline. The final version may differ significantly from the current draft, but having this draft available allows us to gather early feedback and iterate on the design. We welcome feedback and discussion, but please keep in mind that the details are not yet finalized.

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 @NonNull variables/parameters/returns, unguarded @Nullable dereferences, @MonotonicNonNull re-assignment — recognizing annotations by simple name from any package (JSR-305 style, Spring’s legacy annotations, JSpecify names including @NullMarked / @NullUnmarked at class level).

  • A strict option adds method-local flow analysis: null-literal tracking through assignments, ternaries and Elvis expressions, variable-to-variable propagation, guard recognition (x != null comparisons, early exits), and uninitialized-variable detection.

  • groovy-contracts feeds the checker: @Requires/@Ensures conditions infer non-nullness for parameters and returns via StaticTypesMarker.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 @NullMarked scoping), 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 a List<@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 (instanceof narrowing, 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

NonNull

Proved or declared non-null; dereference and passing to non-null positions allowed

Nullable

May be null; unguarded dereference is a diagnostic

Unspecified

No information (unannotated code outside @NullMarked scope, dynamic values); checked leniently, matching JSpecify’s "unspecified" semantics

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 of NullChecker remains for the mixed pre-JSpecify ecosystem, as a configuration choice.

  • Scoping: @NullMarked/@NullUnmarked innermost-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

a?.b

Result is Nullable by definition; (a?.b).c is a diagnostic, a?.b?.c is not. Applying ?. to a receiver proved NonNull earns an info-level "redundant safe navigation" hint (off by default, delight when on)

x ?: d

Result nullness is `d’s nullness (the Groovy-truth semantics of Elvis guarantee the left branch contributes only non-null values)

if (x) { …​ }

Groovy truth narrows x to NonNull within the block (truthiness implies non-null; the converse is not assumed)

*. spread

Propagates: list*.name on a List<@Nullable Foo> element type is a diagnostic on .name; the spread result’s elements are nullable when the receiver list is nullable

def / dynamic values

Unspecified — dynamic Groovy is unchanged; the qualifier only becomes load-bearing where static checking is already in force

Implicit truth conversions

x != null, Objects.nonNull(x), assert x, conjunctions, early exits — the full guard vocabulary of GROOVY-12208, applied as type refinement

Producing side

  • Groovy source can declare @NullMarked at 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/@TypeChecked configuration (or simply the presence of @NullMarked in 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. ?./?:/spread/truth

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 → @TypeChecked → contracts → nullness) presented in one place

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

@NullCheck (AST transform) already exists and remains orthogonal: it hardens at runtime, this GEP verifies at compile time; they compose

Kotlin-style nullable type syntax (String?)

Deferred

Interop-first: JSpecify annotations are the ecosystem’s shared vocabulary. Sugar mapping String? onto the same qualifier could be a later, separate GEP once semantics are proven

Changing dynamic Groovy semantics

Not planned

Dynamic code stays Unspecified; the MOP is untouched

Constructor initialization checking (partially-initialized this escaping, à la Checker Framework’s initialization checker)

Deferred

Valuable but a distinct analysis with its own annotation set; revisit after Phase 3

Full @MonotonicNonNull semantics in core

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 NullChecker indefinitely 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.class of 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/@Ensures via StaticTypesMarker.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