Groovy 6 features for Functional Programmers
Published: 2026-05-19 08:00AM
Introduction
What’s the best way to do functional programming on the JVM?
Groovy starts by inheriting Java’s answer in full. Optional, Stream,
CompletableFuture, Function, records, immutable collections,
virtual threads — every JDK carrier and combinator is available, with
the usual Groovy sugar on top (closures, GINQ, parallel collections,
Awaitable, async/await). For a lot of workaday code, that is already
enough.
When you want more discipline than the JDK ships with — applicative
validation, persistent collections, an effect monad, a Try for
errors-as-values — your favourite existing library still works.
FunctionalJava, HighJ, and Vavr compose in Groovy unchanged; the DO
macro recognises their control carriers by name, no glue code required.
Groovy 6 adds a third option, complementary to both. Keep the plain
values, keep try/catch, keep the carriers Java already gave you —
and shift the discipline onto declarations the compiler verifies.
Purity is @Pure enforced by PurityChecker. Monoid is
@Reducer/@Associative enforced by CombinerChecker. Absence is
@Nullable enforced by NullChecker (with a strict mode that needs
no annotations at all). The discipline the wrapper-heavy path gives
you, without the cost of lifting every call boundary into a carrier —
the simplicity of the pragmatic path with the guarantees of the
elaborate one.
And — relevant before assuming this means scattering annotations throughout your codebase — most of these declarations are a library-side concern: written once by API authors, or by AI agents producing code that downstream readers (human or agent) can then reason about, and consumed by everyday application code without local annotation. We come back to that in Who writes the annotations? below.
The new pieces close a handful of the gaps that send functional programmers reaching for FunctionalJava, HighJ or Vavr when they work on the JVM:
-
@Associative/@Reducerannotations that put a monoid contract on a binary method, verified at compile time byCombinerChecker. -
@Pure/@Modifiesannotations that let the compiler tell you a method has no side effects (or only the ones you allow) — a specification-driven alternative to lifting effects into anIOmonad. -
A
DOmacro (GEP-23) giving Scala-for/ Haskell-donotation acrossOptional,Stream,CompletableFuture, Groovy’sAwaitableandDataflowVariable, recognised FunctionalJava and Vavr carriers, and any user type that opts in. -
NullChecker— including a flow-sensitivestrictmode requiring no annotations — for compile-timeMaybewithout the wrapper. -
val, nestedcopyWithand richer destructuring on top of records and@Immutable, for the everyday immutable-update shapes that otherwise need a lens library. -
@Decreasesand@Invarianton loops, providing termination measures and loop invariants of the kind a totality checker would require.
None of that turns Groovy into a pure-functional language: there are
still no higher-kinded types, no Functor/Applicative/Monad
hierarchy, and no totality checker in the compiler. It does mean the
parts of the FP toolkit that pay their way on a real JVM project —
algebraic laws on combiners, declared purity, monadic value
composition, exhaustive null handling, immutable updates — are now
first-class.
A companion project at
groovy6-functional
holds runnable versions of every example in this post.
What Groovy already gave you
Groovy has been a usable language for functional programmers since the 2.x line. Five things were already in the toolbox:
-
First-class closures and function composition. Closures are values;
<<and>>compose them;curryandrcurrypartially apply.var trim = { String s -> s.trim() } var upper = String::toUpperCase var sizeSq = (String::size) >> { it ** 2 } var composed = sizeSq << (trim >> upper) assert composed(' foo bar ') == 49 -
Memoization and trampolining.
closure.memoize(),memoizeAtMost(n), andClosure.trampoline()turn naive recursive definitions into something fit for production. -
Records,
@Immutable, deep-immutable lists/maps via.asImmutable(). Value semantics without ceremony. -
Tail-recursive methods via
@TailRecursive— the transform rewrites the body to a loop. No stack growth forfactorial,mutualOddEven, list folds. -
Lazy streams and GINQ.
Stream, lazy iterators, and GINQ (a comprehension over collections and SQL-like sources) cover the lazy/declarative end of the lane.
That toolkit was good for the workaday side of FP. It said little about the parts that distinguish FP-with-checking from FP-with-conventions: algebraic laws, declared effects, monadic composition for non-stream carriers, and ironclad immutability. Groovy 6 is mostly about those.
Monoids and Semigroups, checked
A monoid is an associative binary operation with an identity. In Haskell:
class Semigroup a where (<>) :: a -> a -> a
class Semigroup a => Monoid a where mempty :: a
In FunctionalJava you build a Monoid<A> by passing a combiner closure
and a zero. In HighJ you import the typeclass instance for Monoid<µ>.
Vavr stops short of a Monoid abstraction and reduces via
Foldable.reduce instead. While you can also use those libraries from
Groovy as is, you now also have the option of moving the algebra onto
the method:
import groovy.transform.Associative
import groovy.transform.Reducer
class Sum {
@Reducer(zero = '0')
static int add(int a, int b) { a + b }
}
class Concat {
@Reducer(zero = '""')
static String join(String a, String b) { a + b }
}
class Tally {
@Reducer(zero = '[:]')
static Map<String, Integer> merge(Map<String, Integer> a, Map<String, Integer> b) {
var out = new LinkedHashMap(a)
b.each { k, v -> out.merge(k, v) { x, y -> x + y } }
out
}
}
@Reducer is the monoid form: associative plus a named identity. Use
@Associative alone when the operation is associative but has no
natural identity in the problem domain — a semigroup, usable with
unseeded reductions only:
class Largest {
@Associative
static int max(int a, int b) { a >= b ? a : b }
}
The point is not the annotations. The point is the type-checking extension behind them:
@TypeChecked(extensions = 'groovy.typecheckers.CombinerChecker')
def reductions() {
assert (1..100).toList().injectParallel(0, Sum.&add) == 5050
assert ['a', 'b', 'c'].sumParallel(Concat.&join) == 'abc'
assert [3, 1, 4, 1, 5, 9, 2, 6].sumParallel(Largest.&max) == 9
// REJECTED at compile time — subtraction is not associative:
// [1, 2, 3].injectParallel(0) { a, b -> a - b }
}
injectParallel and sumParallel partition, reorder and recombine
input. They are only correct when the combiner is associative — and
that is the bug class FP people normally protect themselves from by
encoding the structure as a Monoid<A> value. A non-associative
combiner like the subtraction example is a semantic error: it
compiles cleanly in Java and in Groovy without the checker, and
surfaces as a non-deterministic wrong answer at runtime. The checker
gives you the same guarantee a typeclass constraint gives Scala or
Haskell, but without lifting add into a wrapper and unlifting the
result.
The Monoid/Semigroup types from FunctionalJava and HighJ are also
recognised, which means existing FJ-shaped combiners flow through
sumParallel without rewriting.
Purity and frame conditions — Groovy’s answer to IO / State
The other classical FP move is to lift effectful work into the type
system: IO for arbitrary effects, State s for threaded state,
Reader r for environment access, Writer w for log accumulation.
Groovy 6 takes a different route to the same outcome — verified
declarations, no lift / unlift:
class Calculator {
BigDecimal total = 0
List<String> ledger = []
@Pure
@TypeChecked(extensions = 'groovy.typecheckers.PurityChecker')
static BigDecimal vat(BigDecimal net, BigDecimal rate) {
net * (1 + rate)
}
@Requires({ amount > 0 })
@Ensures ({ total == old.total + amount })
@Modifies({ [this.total, this.ledger] })
@TypeChecked(extensions = 'groovy.typecheckers.ModifiesChecker')
void post(BigDecimal amount) {
total += amount
ledger << "+$amount".toString()
}
@Pure
@TypeChecked(extensions = 'groovy.typecheckers.PurityChecker(allows: "LOGGING")')
BigDecimal balance() {
log.fine "balance read"
total
}
}
There are four FP-flavoured facts the compiler now knows about
Calculator:
-
vatis pure — referentially transparent in the Haskell sense.PurityCheckerrejects any side-effecting body. -
postmay only modifytotalandledger. Touch any other field andModifiesCheckererrors. This is theStatemonad’s job (what state can change) without the bind boilerplate. -
@Requires/@Ensuresare the Hoare-logic dual ofState’s state-transition function — pre- and post-conditions, with `old.referring to pre-state. -
balanceis pure modulo logging. Its@TypeCheckedextension configuresPurityChecker(allows: "LOGGING"); theallowsoption draws from a small effect lattice (LOGGING,METRICS,IO,NONDETERMINISM) — graded effects, in the cats-effect sense, but the verification work is in the checker rather than the type.
For an FP audience the unfamiliar half is that none of these methods return a wrapped value. The familiar half is that the compiler knows what each method may and may not do, and a reader (or an AI agent) can work from the signature alone.
Monadic comprehensions: DO
GEP-23 introduces DO — a comprehension macro that desugars to the
carrier’s flatMap/map (or thenCompose/thenApply, or whatever
the carrier’s standard pair is). The notation is Scala-for /
Haskell-do shaped:
import static org.apache.groovy.macrolib.MacroLibGroovyMethods.DO
@TypeChecked(extensions = 'groovy.typecheckers.MonadicChecker')
Optional<String> greet(Map<String, String> users, String userId) {
DO(user in Optional.ofNullable(users[userId]),
name in Optional.ofNullable(user.split(/\|/)[0])) {
Optional.of("Hello, $name!".toString())
}
}
assert greet([u42: 'Alice|admin'], 'u42').get() == 'Hello, Alice!'
assert greet([u42: 'Alice|admin'], 'u99') == Optional.empty()
The same shape works over Awaitable (so the dependency graph in an
async computation is in the source rather than spread across
thenCompose calls):
@TypeChecked(extensions = 'groovy.typecheckers.MonadicChecker')
Awaitable<String> greetByKey(String key) {
DO(id in fetchId(key),
name in fetchName(id),
msg in greeting(name)) {
async { msg }
}
}
DO and for await (Groovy 6’s other "await"-shaped construct)
operate at different layers and are easy to confuse. DO composes
one result from a chain of dependent monadic steps; for await
iterates over many values pulled from a stream-shaped source
(Flow.Publisher, AsyncChannel, a generator). If the problem is
"three dependent calls produce a single answer", reach for DO. If it
is "a stream of events to process as they arrive", reach for
for await. The two compose at different layers — a DO chain can
sit inside the body of a for await loop, and an Awaitable produced
by DO can be one element of an upstream publisher consumed by
for await — but neither replaces the other.
DO also composes over FunctionalJava’s Validation — no annotation,
no wrapper, no Groovy dependency on FunctionalJava, because
fj.data.Validation is recognised by name in the standard allow-list:
@TypeChecked(extensions = 'groovy.typecheckers.MonadicChecker')
Validation<String, Integer> add(String a, String b) {
DO(x in parsePositive(a),
y in parsePositive(b)) {
Validation.success(x + y)
}
}
assert add('2', '3').success() == 5
assert add('hi', '3').fail() == 'not numeric: hi'
The same allow-list recognises Vavr’s control carriers
(io.vavr.control.Option, io.vavr.control.Try, io.vavr.control.Either,
io.vavr.control.Validation) by name, so existing Vavr-shaped code
composes through DO without rewriting and without Groovy taking a
dependency on Vavr:
import io.vavr.control.Try
import static org.apache.groovy.macrolib.MacroLibGroovyMethods.DO
@TypeChecked(extensions = 'groovy.typecheckers.MonadicChecker')
Try<Integer> divide(String a, String b) {
DO(x in Try.of { Integer.parseInt(a) },
y in Try.of { Integer.parseInt(b) }) {
Try.success(x.intdiv(y))
}
}
assert divide('20', '5').get() == 4
assert divide('hi', '5').isFailure() // short-circuits at x
assert divide('20', '0').isFailure() // short-circuits at the body
Vavr ships its own For(…).yield(…) form ("For-Comprehension") that
serves the same purpose in straight Java; DO reads as the Groovy-native
analogue, and a codebase that already uses Vavr-shaped control types in Java
gets the comprehension for free.
What DO does not do: it does not introduce higher-kinded types, it
does not mix carriers in one comprehension (nest for that), and it does
not synthesise a pure/return — the body must explicitly yield a
carrier value. That is the deliberate non-goal list in
GEP-23.
Linting native chains: MonadicShapeChecker
For codebases that prefer flatMap/map chains over comprehensions,
MonadicShapeChecker lints the chain against the same registry that
DO uses. It catches three classic foot-guns:
| Mistake | Checker says |
|---|---|
|
|
|
bind must return the same carrier |
|
|
The first two would be compile errors in Java, but Groovy’s
single-abstract-method coercion of closures normally lets them through —
the checker restores the Java guarantee. The third compiles cleanly in
both Java and Groovy: an Optional<Optional<Integer>> is a perfectly
well-typed value, just (almost certainly) not the one you intended. That
is the flatMap/map mix-up the checker catches for everyone.
Compile-time Maybe without the wrapper
In Haskell, Maybe a is a discipline you adopt: every value that
might be absent is wrapped, the compiler tracks it, you handle the
Nothing case at the leaf.
In Groovy 6, NullChecker is the equivalent discipline without the
wrapper. You can drive it with @Nullable/@NonNull if you like:
@TypeChecked(extensions = 'groovy.typecheckers.NullChecker')
int safeLength(@Nullable String text) {
if (text != null) {
return text.length() // ok — narrowed
}
-1
}
…but a deliberate goal of the Groovy 6 approach is to not make you
litter code with annotations. Flow-sensitive strict mode does the
same analysis on entirely unannotated code:
@TypeChecked(extensions = 'groovy.typecheckers.NullChecker(strict: true)')
def strictDemo() {
def x = null
// x.toString() // compile error: x may be null
x = 'hello'
assert x.toString() == 'hello' // ok — reassigned
}
That covers code under your control. The trickier case is the boundary: a third-party library that exposes possibly-null returns and never annotated them. Groovy 6 stacks a few mitigations here:
-
@Nullable/@NonNullare matched by simple name from any package — JSpecify, JSR-305, JetBrains, SpotBugs, Checker Framework, or your own marker — so whichever vendor’s annotations the library author did or didn’t pick, the checker sees them. -
Where the library is annotation-free, contract annotations contribute nullability facts: a top-level
x != nullconjunct in@Requiresmarks the parameter as implicitly@NonNull, andresult != nullin@Ensuresdoes the same for the return. You assert the boundary fact once at your wrapping method and the checker propagates it inward. -
@NullCheckauto-generates null guards on parameters where you want fail-fast behaviour rather than tracked nullability. -
@MonotonicNonNullcovers the lazy-init case (read as nullable until first assignment, non-null afterwards). -
Safe navigation (
?.) and the Elvis operator (?:) — Groovy features that pre-date NullChecker — are recognised by the checker as narrowing forms, so the idiomaticlib.maybe()?.size() ?: 0typechecks without further annotation.
The trade — versus Optional<T> everywhere — is the one Kotlin made:
the analysis is on the variable, not in the type, and the cost at the
call site is zero.
Immutability ergonomics
Three Groovy 6 changes make the day-to-day immutable-update shapes
match what you get from a Haskell record-update syntax or a Scala
copy call:
@Immutable(copyWith = true) class Address { String city, zip }
@Immutable(copyWith = true) class Person { String name; Address address }
val alice = new Person('Alice', new Address('NYC', '10001'))
val moved = alice.copyWith('address.city': 'Boston')
assert moved.address.zip == '10001' // structural sharing — untouched
val swapped = alice.copyWith {
name = 'Alice2'
address.city = old.address.city.reverse()
}
val (name: who, age: _) = [name: 'Bob', age: 30]
def (h, *t) = [1, 2, 3, 4]
val is the immutable-binding companion to var. Nested copyWith
gives you lens-style updates without a lens library — structural
sharing is preserved transitively, so is-identity holds on untouched
branches. The destructuring extension reaches the "match the parts you
care about" cases pattern matching is normally used for.
Termination and loop invariants
Totality matters to FP people. @TailRecursive was already there;
@Decreases and @Invariant on loops are new:
@Ensures({ result.isSorted() })
List merge(List in1, List in2) {
var out = []
var count = in1.size() + in2.size()
@Invariant({ in1.size() + in2.size() + out.size() == count })
@Decreases({ [in1.size(), in2.size()] })
while (in1 || in2) {
if (!in1) return out + in2
if (!in2) return out + in1
out += (in1[0] < in2[0]) ? in1.pop() : in2.pop()
}
out
}
The @Invariant says nothing is lost or gained across iterations.
The @Decreases gives a lexicographic termination measure: the pair
[in1.size(), in2.size()] strictly decreases each round. That is the
sort of obligation a totality checker in Idris or Agda would impose.
The checker is contract-grade rather than proof-grade, but the
declaration is the same shape.
Property-based tests, mechanically derived from the annotations
The single biggest payoff of these annotations is that they are not just for the compiler. They are machine-readable, structurally identical across the codebase, and compiler-enforced — so an AI agent or a small ASM walker can use them as a specification:
Prompt:
For every static method annotated @groovy.transform.Associative,
emit a jqwik @Property method asserting f(f(a,b), c) == f(a, f(b,c)).
If the method also carries @Reducer(zero = "<expr>"), emit a second
@Property asserting f(a, <expr>) == a and f(<expr>, a) == a.
Which produces, mechanically:
class MonoidLawsTest {
@Property boolean addIsAssociative(@ForAll int a, @ForAll int b, @ForAll int c) {
Sum.add(Sum.add(a, b), c) == Sum.add(a, Sum.add(b, c))
}
@Property boolean addHasZero(@ForAll int a) {
Sum.add(a, 0) == a && Sum.add(0, a) == a
}
@Property boolean tallyIsAssociative(@ForAll Map<String, Integer> a,
@ForAll Map<String, Integer> b,
@ForAll Map<String, Integer> c) {
Tally.merge(Tally.merge(a, b), c) == Tally.merge(a, Tally.merge(b, c))
}
}
The agent never reads the body of add or merge. The compile-time
CombinerChecker is the guarantee that the annotation is trustworthy
enough to treat as a specification.
The same idea applies elsewhere:
| Annotation | Derivable property |
|---|---|
|
|
|
|
|
invocation idempotent, no observable effect |
|
every field not listed is bit-for-bit identical after the call |
This is the Groovy 6 design pitch in one sentence: declarations whose compile-time guarantee makes them safe for an agent to read as a spec, not as a comment.
Who writes the annotations?
A reader skimming this post might infer that turning these features on
means scattering @Pure, @Modifies, @Associative, @Reducer,
@Monadic, @Nullable and friends throughout application code. That is
not the intent.
Declarations are a producer-side property; checkers are a consumer-side property. The natural division of labour:
-
Libraries — Groovy stdlib, the JDK, FP-style libraries, and domain frameworks — declare annotations on the APIs they own. A repository library marks its lookup methods
@Nullable User findById(Long id); a monetary library carries@Associative @Reducer(zero = '0')on itsadd; a domain service marks its mutating methods with@Modifies({…})and its read methods with@Pure; a monadic carrier carries@Monadic(or its conventional method names match the structural rule and no annotation is needed at all). JSpecify, JSR-305, JetBrains and SpotBugs@Nullable/@NonNullannotations — matched by simple name from any package — already feedNullCheckerwithout anyone changing the library. -
User code consumes those declarations. Calling
repository.findById(id)under@TypeChecked(extensions = 'NullChecker(strict: true)')is enough to surface a "may dereference null" diagnostic — the library has already declared the contract. CallingsumParallel(money.&add)underCombinerCheckervalidates against the library’s@Associative. Callingservice.update(x)underModifiesCheckervalidates against the library’s frame condition. No annotation on the user’s side. -
Registries fill the gap when the library is owned elsewhere and not yet annotated. Vavr’s control types compose through
DObecause the core allow-list names them, not because Vavr was modified or because the user wrote@Monadicon a wrapper. -
Config scripts and compile-time customisers remove the only annotation that user files do otherwise carry — the
@TypeChecked(extensions = '…')that turns each checker on. A singleASTTransformationCustomizerregistered in a Groovy-configscriptenables the relevant checker for an entire module; build-tool equivalents exist for Gradle (compileGroovy.groovyOptions.configurationScript) and Maven. The examples in this post carry the per-file annotation as a pedagogical convenience — a reader can then see exactly which checker is in play — but the production deployment lives in build config, once, and user files stay annotation-free.
User code acquires annotations of its own only at system boundaries —
public APIs the user owns, framework handlers, code that needs to
propagate a contract to its own callers, or local logic the user wants
the compiler to double-check against an @Ensures postcondition. For
everyday application code that just calls libraries, the checkers do
their work against the libraries' declarations and the application
stays annotation-free.
This matters for every checker in the post. The
"compile-time Maybe without the wrapper" pitch reaches full strength
exactly when the libraries you depend on already carry @Nullable /
@NonNull (or were compiled with JSpecify) — NullChecker then
delivers the static guarantee on the consumer file with no local
ceremony. The same property holds for CombinerChecker,
PurityChecker, ModifiesChecker, MonadicChecker and the contract
annotations. The annotations are an investment the library author
makes once; every downstream codebase running the matching checker
recovers the value of it on every build.
GEP-24 is the natural extension of this
story to errors: a candidate for Groovy 7 sketching library-side
@Raises plus consumer-side ExceptionChecker for the declaration
path, and an opt-in Result<T> carrier (with an attempt { } macro
and an @AsResult AST transform) recognised by DO for the
runtime-composition path — all following the same producer-side /
consumer-side split.
How it stacks up
For a JVM-resident FP audience, the comparison set is FunctionalJava, Vavr, HighJ, and the language alternatives Scala and Kotlin.
| Concept | Groovy 6 | FunctionalJava | Vavr | HighJ | Scala | Haskell |
|---|---|---|---|---|---|---|
Closures and composition |
native ( |
|
|
|
native ( |
native |
Monoid / Semigroup |
|
|
n/a (uses |
|
|
|
Purity & effects |
|
n/a (convention only) |
|
|
|
|
Frame conditions |
|
n/a |
n/a |
n/a |
n/a (libraries) |
n/a |
Monadic comprehension |
|
n/a (hand-written |
|
simulated, with µ tags |
|
|
Null / absence |
|
|
|
|
|
|
Validation / errors |
|
|
|
|
|
|
Persistent collections |
|
|
|
n/a |
|
native (persistent everywhere) |
Termination |
|
n/a |
n/a |
n/a |
n/a (libraries) |
totality checker (extensions) |
Higher-kinded abstraction |
no |
no (encoded by hand) |
no (encoded by hand) |
simulated HKT |
native |
native |
The point of the table is not that Groovy 6 ranks ahead of Scala or Haskell on any of these rows. It is that the column for the checked features used to be empty in Java’s heritage — and now isn’t.
Conclusion
Groovy 6 is not asking you to think about endofunctors. It is asking you to declare what your methods do, accepting that the compiler will check, and giving you the notation to compose values across the carrier types you already use.
-
Monoid / Semigroup is now a contract on a method, not a wrapper type — verified by
CombinerChecker. -
@Pure,@Modifiesand the contract annotations turn each method into a self-contained spec — a different shape fromIO/State, the same reasoning payoff. -
DOgives you thefor/donotation acrossOptional,Stream,CompletableFuture,Awaitable, the FunctionalJava and Vavr control carriers (io.vavr.control.{Option, Try, Either, Validation}), and any user type with the right shape, without committing the language to higher-kinded types. -
NullChecker, nestedcopyWith, destructuring,val,@Decreasesand@Invariantfill the everyday gaps that send people to libraries.
For new code on the JVM, the takeaway is that @Pure, @Modifies,
@Associative and @Reducer cover most of what teams used
FunctionalJava and HighJ for — and they do it on your own types,
without inheritance and without lift/unlift. FunctionalJava, HighJ, and
Vavr remain useful when you want the libraries' richer value-level
combinators: FunctionalJava and Vavr for applicative-style error
accumulation via Validation (which Groovy 6 happily embeds in DO);
Vavr additionally for Try/Either as runtime-composable carriers
where failure-as-a-value is the requirement, and for persistent
collections with structural sharing — both gaps not yet filled in
Groovy core. The Try/Either gap is the subject of
GEP-24, a Groovy 7 candidate that proposes
spec-side @Raises + ExceptionChecker and carrier-side Result<T>
following the same two-layer pattern used elsewhere in this post.
References
-
GEP-24 — Errors as values: compile-time
Trywithout the wrapper (Groovy 7 candidate) -
Groovy 6 release notes — Designed for Human and AI Reasoning
-
Native Async/Await for Groovy — the
AwaitablecarrierDOcomposes -
Vavr — Java FP library with
Try/Either/Validation/Optioncontrol carriers, persistent collections, tuples, and theFor(…).yield(…)For-Comprehension form. The control carriers are in `DO’s standard allow-list (Groovy 6).
