Groovy 6 features for Functional Programmers

Author:  Paul King
PMC Member

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/@Reducer annotations that put a monoid contract on a binary method, verified at compile time by CombinerChecker.

  • @Pure/@Modifies annotations 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 an IO monad.

  • A DO macro (GEP-23) giving Scala-for / Haskell-do notation across Optional, Stream, CompletableFuture, Groovy’s Awaitable and DataflowVariable, recognised FunctionalJava and Vavr carriers, and any user type that opts in.

  • NullChecker — including a flow-sensitive strict mode requiring no annotations — for compile-time Maybe without the wrapper.

  • val, nested copyWith and richer destructuring on top of records and @Immutable, for the everyday immutable-update shapes that otherwise need a lens library.

  • @Decreases and @Invariant on 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:

  1. First-class closures and function composition. Closures are values; << and >> compose them; curry and rcurry partially 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
  2. Memoization and trampolining. closure.memoize(), memoizeAtMost(n), and Closure.trampoline() turn naive recursive definitions into something fit for production.

  3. Records, @Immutable, deep-immutable lists/maps via .asImmutable(). Value semantics without ceremony.

  4. Tail-recursive methods via @TailRecursive — the transform rewrites the body to a loop. No stack growth for factorial, mutualOddEven, list folds.

  5. 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:

  • vat is pure — referentially transparent in the Haskell sense. PurityChecker rejects any side-effecting body.

  • post may only modify total and ledger. Touch any other field and ModifiesChecker errors. This is the State monad’s job (what state can change) without the bind boilerplate.

  • @Requires/@Ensures are the Hoare-logic dual of State’s state-transition function — pre- and post-conditions, with `old. referring to pre-state.

  • balance is pure modulo logging. Its @TypeChecked extension configures PurityChecker(allows: "LOGGING"); the allows option 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

Optional.of(1).flatMap { it + 1 }

flatMap must return a carrier

Stream.of(1).flatMap { Optional.of(it) }

bind must return the same carrier

Optional.of(1).map { Optional.of(it) }

M<M<T>> — did you mean flatMap?

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/@NonNull are 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 != null conjunct in @Requires marks the parameter as implicitly @NonNull, and result != null in @Ensures does the same for the return. You assert the boundary fact once at your wrapping method and the checker propagates it inward.

  • @NullCheck auto-generates null guards on parameters where you want fail-fast behaviour rather than tracked nullability.

  • @MonotonicNonNull covers 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 idiomatic lib.maybe()?.size() ?: 0 typechecks 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

@Associative on f(a, b)

f(f(a, b), c) == f(a, f(b, c))

@Reducer(zero = z) on f(a, b)

f(a, z) == a and f(z, a) == a

@Pure (no allows)

invocation idempotent, no observable effect

@Modifies({ [fields…​] })

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 its add; 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/@NonNull annotations — matched by simple name from any package — already feed NullChecker without 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. Calling sumParallel(money.&add) under CombinerChecker validates against the library’s @Associative. Calling service.update(x) under ModifiesChecker validates 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 DO because the core allow-list names them, not because Vavr was modified or because the user wrote @Monadic on 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 single ASTTransformationCustomizer registered in a Groovy -configscript enables 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 (>>, <<, curry)

fj.F, .o(), curry on F2..F8

Function0..Function8, andThen/compose/curried/memoized

Functions combinators

native (andThen, compose)

native

Monoid / Semigroup

@Associative/@Reducer + CombinerChecker

Monoid<A>, Semigroup<A> values

n/a (uses Foldable.reduce)

Monoid<µ> typeclass instance

cats.Monoid typeclass

Monoid class

Purity & effects

@Pure(allows = …​) + PurityChecker

n/a (convention only)

Try lifts effects to a value

IO<µ> simulation

cats.effect.IO

IO, State, etc.

Frame conditions

@Modifies + ModifiesChecker

n/a

n/a

n/a

n/a (libraries)

n/a

Monadic comprehension

DO (GEP-23, no HKT)

n/a (hand-written .bind)

For(…​).yield(fn) ("For-Comprehension")

simulated, with µ tags

for { …​ } yield

do { …​ }

Null / absence

NullChecker (strict)

fj.data.Option

Option

Maybe<µ>

Option

Maybe

Validation / errors

fj.data.Validation and io.vavr.control.{Try, Either, Validation} in DO

Validation<E, A>

Try, Either, Validation

Either<µ, µ>

Either, Validated

Either, Validation

Persistent collections

.asImmutable() (view); @Immutable (deep-frozen)

fj.data.List, fj.data.Stream

io.vavr.collection.{List, Vector, HashMap, …​} (HAMT-based, structural sharing)

n/a

scala.collection.immutable.* (persistent)

native (persistent everywhere)

Termination

@Decreases (Hoare)

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, @Modifies and the contract annotations turn each method into a self-contained spec — a different shape from IO/State, the same reasoning payoff.

  • DO gives you the for/do notation across Optional, 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, nested copyWith, destructuring, val, @Decreases and @Invariant fill 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

Update history

22/May/2026: Vavr added to comparison set and DO allow-list; producer-side / consumer-side framing for declaration-driven features; for await vs DO distinction; GEP-24 forward-reference for the Try / errors-as-values gap.

20/May/2026: Initial version.