GEP-24


Metadata
Number

GEP-24

Title

Errors as values — compile-time Try without the wrapper

Version

1

Type

Feature

Status

Draft

Comment

Targets Groovy 7.0; the spec-side checker (@Raises + ExceptionChecker) may incubate earlier in a Groovy 6.x point release

Leader

Paul King

Created

2026-05-21

Last modification

2026-05-21

Note
WARNING: Material on this page is still under development! We are currently working on Groovy 6.0 and this proposal targets Groovy 7.0. The final version of this proposal may differ significantly from the current draft, but having this draft available allows us to gather early feedback, align design decisions in Groovy 6 as best we can, and iterate on the design. We welcome feedback and discussion, but please keep in mind that the details are not yet finalized.

Abstract

This GEP brings Vavr / Scala / Rust style "errors as values" composition to Groovy without committing to a single runtime carrier. It splits the functionality into two complementary layers, mirroring the approach Groovy 6 already takes for nullability (@Nullable + NullChecker / Optional<T> in DO):

  • Spec layer@Raises(X.class) on Groovy methods plus groovy.typecheckers.ExceptionChecker, a flow-sensitive type-checking extension that verifies every potentially-thrown exception is either declared or discharged. This is "compile-time Try without the wrapper" — the runtime stays plain try/catch and stack traces stay legible.

  • Carrier layergroovy.util.Result<T> (a sealed Success/Failure pair), an attempt { … } macro that lifts an exception-throwing block into a Result, an @AsResult AST transform that lifts a whole method body, and an entry in DO’s standard allow-list so `Result composes monadically alongside Optional, Awaitable, fj.data.Validation, etc.

The two layers are independent: the spec layer is the default, lighter path for code that just wants the compile-time guarantee; the carrier layer is opt-in when failure genuinely needs to be a first-class value (collecting per-element outcomes, structured logging, railway-style chaining, passing failures across module boundaries).

The proposal introduces:

  • groovy.transform.Raises — repeatable marker annotation declaring the exception types a method may throw, suitable for both Groovy and Java consumers (the latter via the bytecode signature emitted by the AST transform).

  • groovy.typecheckers.ExceptionChecker — type-checking extension with default (lenient) and strict modes.

  • groovy.util.Result<T> — sealed interface with Success<T> and Failure<T> record implementations, exposing map/flatMap/recover/recoverWith/onSuccess/onFailure/ getOrElse/getOrThrow/toOptional/toAwaitable.

  • attempt { … } — macro in the macro library that lifts a block to a Result<T>.

  • groovy.transform.AsResult — AST transform that rewrites a method body into the corresponding try/catch returning a Result<T>.

  • Result entered into MonadicChecker / MonadicShapeChecker / DO carrier registry from GEP-23.

Motivation

The Groovy 6 design pitch in one sentence is verified declarations beat runtime wrappers. The proof points are already in place:

Library concept Spec layer in Groovy 6 Carrier layer in Groovy 6

Maybe/Option

@Nullable + NullChecker(strict)

Optional<T> in DO

Monoid<A> / Semigroup<A>

@Reducer/@Associative + CombinerChecker

(none — algebra moves onto the method)

IO / State

@Pure(allows = …) / @Modifies + checkers

(none — declarations replace lift/unlift)

do-notation

MonadicChecker

DO macro (GEP-23)

Async

@Pure(allows = NONDETERMINISM)

async { } macro + Awaitable

The conspicuous gap is errors as values. A Groovy 6 codebase that wants Vavr-style Try.of) → risky(.map(…).recover(…) semantics has no native answer: Vavr (or fj’s Validation, or hand-rolled Either) must be imported, and the resulting code lifts every failable call into a wrapper that does not compose with the rest of the Groovy 6 surface (it is invisible to NullChecker, PurityChecker, MonadicChecker, and the contract annotations).

The deliberate Groovy 6 stance on exception handling (per release notes, async/await section) is "Exception handling works with standard try/catch — no .exceptionally() chains." This GEP keeps that stance for the everyday path and adds opt-in mechanisms — at the declaration level, not the type level — for code that wants compile-time guarantees about which exceptions cross which boundaries, plus a runtime Result<T> carrier when failure genuinely is a value.

The companion observation is that every existing Groovy 6 spec-layer mechanism already has a runtime sibling that participates in DO. The same two-layer pattern applied to errors completes the picture.

Goals

  1. Provide a declaration-driven, compile-time-verifiable way to track which exceptions cross which boundaries, without re-introducing Java’s language-level checked-exception ceremony.

  2. Provide a runtime Result<T> carrier with the standard map/flatMap/recover API, idiomatic for Groovy, free of µ-tag encodings and free of HKT machinery.

  3. Make the spec layer and the carrier layer interoperate without ceremony: the same @Raises declaration informs the checker and the AST transform; Result.toAwaitable() and attempt { } desugar via the same primitives already used by async { } / Awaitable.exceptionally.

  4. Recognise existing third-party error carriers (Vavr’s Try / Either / Validation, fj’s Validation) in `DO’s allow-list by simple name, so codebases that already use them get DO composition for free.

  5. Provide machine-actionable specs: @Raises declarations are compiler-enforced and AI-readable in the same way @Pure / @Modifies / @Associative / @Reducer already are.

Non-goals

  1. Re-introducing Java’s checked-exception ceremony at the language level. The spec layer is opt-in per file via the @TypeChecked extension; default Groovy code is unchanged.

  2. Higher-kinded types. Result<T> is a concrete type. No Monad<µ> typeclass, no synthesised pure/return, no carrier-mixing in DO. Same non-goals as GEP-23.

  3. A Try language keyword. The block-level lift is the attempt { } macro; the method-level lift is the @AsResult AST transform. The Java keyword try is unchanged in meaning.

  4. A new exception hierarchy. Result.Failure wraps Throwable; existing exception classes are reused as-is.

  5. Supervision strategies for actors / agents. Those remain a separate question for groovy.concurrent.Actor / Agent.

Specification

Spec layer

@Raises annotation

Marker on methods that may propagate one or more exceptions to callers, in addition to (or beyond) anything visible in the method’s throws clause. Repeatable so individual exception types can be listed per-line for readability.

package groovy.transform

import org.apache.groovy.lang.annotation.Incubating

@Documented
@Incubating
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Repeatable(Raises.Container)
@interface Raises {
    Class<? extends Throwable>[] value()
    String when() default ''             // optional condition expression, for docs / tooling

    @Documented
    @Incubating
    @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.RUNTIME)
    @interface Container { Raises[] value() }
}

Usage:

@Raises(NumberFormatException)
int parsePositive(String s) {
    var n = Integer.parseInt(s)            // throws NFE
    if (n <= 0) throw new IllegalArgumentException("not positive: $s")
    n
}

// Repeatable:
@Raises(NumberFormatException)
@Raises(value = IllegalArgumentException, when = 'n <= 0')
int parsePositive2(String s) { … }

The AST transform that backs @Raises also emits a bytecode throws clause on the generated method, so Java consumers see the declaration in the standard place. (For Groovy methods that already use Java-style throws X in the signature, @Raises is redundant and the checker treats both as authoritative.)

ExceptionChecker

A type-checking extension under groovy.typecheckers.ExceptionChecker, used like any other extension:

// Lenient mode — flags only unhandled checked exceptions and unhandled
// exceptions declared via @Raises that appear at call sites.
@TypeChecked(extensions = 'groovy.typecheckers.ExceptionChecker')

// Strict mode — additionally flags every potentially-thrown unchecked
// exception that can be statically inferred from bytecode / @Raises,
// requiring an explicit catch, rethrow declaration, or @Raises propagation
// on the enclosing method.
@TypeChecked(extensions = 'groovy.typecheckers.ExceptionChecker(strict: true)')

Inference sources (in priority order):

  1. @Raises(X) on the called method (Groovy-side declaration).

  2. Bytecode throws clause on the called method (Java methods and Groovy methods declaring throws X).

  3. The @Pure family — a method declared @Pure(allows = NONE) (the default) is taken to throw nothing. A @Pure(allows = THROWS_SAFELY) variant may be added if useful (subject to discussion).

  4. (strict mode only) Standard JVM unchecked exceptions implied by the expression form — division by zero, array bounds, null dereferences (when not separately discharged by NullChecker), ClassCastException in casts, etc., catalogued in a configurable table.

Discharge mechanisms (a hit on any of these removes the obligation):

  1. try/catch whose catch covers the inferred type (or a supertype). The checker handles multi-catch and Throwable / Exception / RuntimeException catch-all forms.

  2. ARM / try-with-resources blocks for resources whose close() declares the exception in question.

  3. groovy.util.IOGroovyMethods.withCloseable / withStream / similar closure-scoped resource methods, recognised by name.

  4. @Raises(X) on the enclosing method — explicit propagation.

  5. throws X clause on the enclosing method — same, Java-side spelling.

  6. A @Requires({ … }) whose top-level conjuncts statically rule out the precondition that would cause the throw (parallel to how @Requires({ x != null }) already feeds NullChecker). Example: @Requires({ s ==~ /\d+/ }) discharges NumberFormatException from a subsequent Integer.parseInt(s).

  7. attempt { } — see below.

  8. @AsResult — see below.

Diagnostic shape (same idiom as NullChecker):

ExceptionChecker: 'parsePositive' may throw NumberFormatException;
  declare via @Raises, add it to the method's throws clause, or wrap the
  call in a try/catch / attempt { } block.

Carrier layer

Result<T>

A new type under groovy.util.Result<T>:

package groovy.util

import org.apache.groovy.lang.annotation.Incubating

@Incubating
sealed interface Result<T> permits Success, Failure {

    static <T> Result<T> success(T value)            { new Success<>(value) }
    static <T> Result<T> failure(Throwable error)    { new Failure<>(error) }

    /** Lift a thunk; any Throwable becomes Failure. Used by the attempt macro. */
    static <T> Result<T> of(Supplier<T> thunk)       { try { success(thunk.get()) } catch (Throwable t) { failure(t) } }

    <U> Result<U> map(Function<? super T, ? extends U> fn)
    <U> Result<U> flatMap(Function<? super T, Result<U>> fn)
    Result<T>     recover(Function<? super Throwable, ? extends T> fn)
    Result<T>     recoverWith(Function<? super Throwable, Result<T>> fn)
    Result<T>     filter(Predicate<? super T> pred, Supplier<? extends Throwable> ifFalse)

    void          onSuccess(Consumer<? super T> fn)
    void          onFailure(Consumer<? super Throwable> fn)

    T             getOrElse(T fallback)
    T             getOrThrow()                          // unwraps Success; rethrows wrapped Throwable from Failure
    boolean       isSuccess()
    boolean       isFailure()

    Optional<T>   toOptional()
    Awaitable<T>  toAwaitable()                         // completed CompletableFuture-backed
}

@Incubating
record Success<T>(T value)        implements Result<T> { … }

@Incubating
record Failure<T>(Throwable error) implements Result<T> { … }

Choice notes:

  • sealed interface + record permits members enables GEP-19 record-pattern deconstruction at the case site without special compiler support:

    switch (compute(input)) {
        case Success(var v) -> use(v)
        case Failure(var e) -> log.warn("failed", e)
    }
  • Result.of(Supplier) is the runtime primitive; the macro and AST transform desugar to it. The try/catch lives in one place, in the JDK-trusted groovy.util.Result implementation.

  • Result is not an Awaitable (despite both carrying success-or-failure) — Awaitable semantics imply scheduling; Result is synchronous. Use result.toAwaitable() for explicit interop.

  • The catch in map/flatMap is Throwable except for VirtualMachineError, ThreadDeath, and LinkageError, which propagate (matching Vavr’s "fatal exceptions" rule).

attempt { … } macro

A macro in org.apache.groovy.macrolib.MacroLibGroovyMethods (alongside DO) lifting an arbitrary block to a Result<T>:

import static org.apache.groovy.macrolib.MacroLibGroovyMethods.attempt

Result<Integer> r = attempt { Integer.parseInt(s) }

Result<Integer> r2 = attempt {
    var x = Integer.parseInt(s1)
    var y = Integer.parseInt(s2)
    x + y
}

The macro rewrites to a call on Result.of:

Result<Integer> r = Result.of(() -> Integer.parseInt(s))

The static type inferred for the call is Result<T> where T is the static type of the block’s last expression (mirroring `async { }’s type inference).

Interaction with ExceptionChecker: an attempt { } block discharges every exception that would otherwise escape that block — same effect as wrapping in try { … } catch (Throwable t) { … }.

@AsResult AST transform

A method-level annotation that rewrites the body to return a Result<T>, catching any throw and converting it to Failure:

@AsResult
Result<Integer> parseAndAdd(String s1, String s2) {
    Integer.parseInt(s1) + Integer.parseInt(s2)
}

// Rewrites to:
Result<Integer> parseAndAdd(String s1, String s2) {
    try {
        Result.success(Integer.parseInt(s1) + Integer.parseInt(s2))
    } catch (Throwable t) {
        Result.failure(t)
    }
}

@AsResult(catches = [NumberFormatException, IOException]) for selective catching — uncaught throws propagate normally. Fatal errors (VirtualMachineError, ThreadDeath, LinkageError) always propagate.

Constraints:

  1. The declared return type of the annotated method must be Result<T> (or a supertype). The transform errors at compile time otherwise.

  2. The body’s last expression’s static type must conform to T. Reuses the existing return-type inference machinery.

  3. @AsResult is mutually exclusive with @Raises: the method either reifies failures as Result or declares them via @Raises. The checker errors on combination.

DO and MonadicChecker integration

Result<T> is added to the standard carrier registry consumed by groovy.typecheckers.MonadicChecker, MonadicShapeChecker, and the DO macro from GEP-23:

@TypeChecked(extensions = 'groovy.typecheckers.MonadicChecker')
Result<Integer> compute(String a, String b) {
    DO(x in attempt { Integer.parseInt(a) },
       y in attempt { Integer.parseInt(b) }) {
        Result.success(x + y)
    }
}

assert compute('2', '3') instanceof Success
assert compute('hi', '3') instanceof Failure

DO short-circuits on the first Failure (via flatMap’s pass-through semantics), so the second `parseInt is skipped when the first fails. No new macro work; just an allow-list entry.

MonadicShapeChecker likewise lints Result.of(() → x).map { Result.success(it) } as a flatMap/map mix-up (the body returns a Result, so it should be flatMap). Same checker, new carrier — no new checking logic.

Third-party carrier recognition

DO’s standard allow-list is extended to recognise the following error carriers by simple name from any package, in the same manner @Reducer / @Associative are recognised:

Carrier bind/map convention used by DO

io.vavr.control.Try

flatMap / map

io.vavr.control.Either

flatMap / map

io.vavr.control.Validation

flatMap / map

io.vavr.control.Option

flatMap / map

fj.data.Validation

bind / map (already in standard allow-list)

fj.data.Either

right.bind / right.map

These additions do not require Groovy to depend on Vavr or FunctionalJava — recognition is by simple class name, the same trick used for the existing allow-list entries.

Examples

Spec layer only — no carrier in sight

@Raises(IOException)
String readConfig() {
    Files.readString(Path.of('config.yml'))
}

@TypeChecked(extensions = 'groovy.typecheckers.ExceptionChecker')
def main() {
    var cfg = readConfig()         // compile error: unhandled IOException
}

@TypeChecked(extensions = 'groovy.typecheckers.ExceptionChecker')
@Raises(IOException)
def main2() {
    var cfg = readConfig()         // ok: propagated via @Raises
    process(cfg)
}

@TypeChecked(extensions = 'groovy.typecheckers.ExceptionChecker')
def main3() {
    try {
        var cfg = readConfig()
        process(cfg)
    } catch (IOException ioe) {
        log.error("config load failed", ioe)
    }
}

This is "Try, the static analysis" — no Result<T> anywhere, no lift, no unlift. The stack trace from an unhandled IOException in main2 is the ordinary JVM stack trace.

Carrier layer — failure as a value

import groovy.util.Result
import static org.apache.groovy.macrolib.MacroLibGroovyMethods.attempt

@TypeChecked
List<Result<Integer>> parseAll(List<String> inputs) {
    inputs.collect { s -> attempt { Integer.parseInt(s) } }
}

var outcomes = parseAll(['1', '2', 'oops', '4'])
assert outcomes.count { it.isSuccess() } == 3
outcomes.findAll { it.isFailure() }.each { f ->
    log.warn("input rejected", (f as Failure).error)
}

Here Result<T> earns its place: per-element outcomes are stored in a list, both branches are observable, no exception escapes the loop.

Method-level lift via @AsResult

@AsResult
Result<Config> loadConfig(Path path) {
    var raw = Files.readString(path)               // may throw IOException
    var parsed = ConfigParser.parse(raw)            // may throw ParseException
    parsed.validate()                                // may throw ValidationException
    parsed
}

The body reads as normal throwing code; the annotation provides the boundary. Caller composes via Result.map / Result.flatMap / DO:

@TypeChecked(extensions = 'groovy.typecheckers.MonadicChecker')
Result<App> startUp(Path configPath, Path keysPath) {
    DO(cfg  in loadConfig(configPath),
       keys in loadKeys(keysPath)) {
        Result.success(new App(cfg, keys))
    }
}

Mixing with Awaitable

@AsResult
Result<UserProfile> loadProfile(String id) {
    var raw = await async { httpClient.get("/users/$id") }    // may throw
    UserProfile.parse(raw)
}

// Or sync → async interop:
Awaitable<UserProfile> profileAsync = loadProfile('u42').toAwaitable()

Awaitable.exceptionally and Result.recover are the synchronous / asynchronous siblings — same algebra.

Annotations live where the API lives

@Raises is a library-author annotation, not an application-developer one. A typed CSV parser declares @Raises(MalformedCsvException) on its parse methods once; every caller compiled under ExceptionChecker benefits without writing any annotation of their own. The same applies to JDK methods (Java’s existing throws clauses are read by ExceptionChecker as @Raises-equivalent declarations).

Application code acquires @Raises only at boundaries it owns and propagates — public APIs, framework handlers, top-level methods that need callers to see the contract. The everyday case of "call library; either handle the failure or propagate to caller" needs no local annotation beyond the optional @Raises on the boundary method itself, and that one annotation re-emits as a bytecode throws clause so the contract is visible to Java consumers without further coordination.

This is the same producer-side / consumer-side split that @Nullable/NullChecker, @Associative/CombinerChecker, @Monadic/MonadicChecker and @Pure/PurityChecker already adopt in Groovy 6: the declaration is an investment the library author makes once; the verification is a property consumer codebases enable per-file. A consequence worth naming explicitly is that the "compile-time Try without the wrapper" pitch reaches full strength only when the libraries the user depends on are themselves annotated (or were compiled with Java’s throws clauses, which works equally well). Annotating a single well-used library benefits every downstream codebase that runs ExceptionChecker; the cost is paid once.

The carrier-side path is the symmetric story for the runtime composition layer: Result<T> enters DO’s allow-list in core, so codebases that lift to `Result get DO composition without writing @Monadic themselves. Vavr’s Try/Either/Validation/Option and FunctionalJava’s Validation are recognised by name for the same reason — application code chooses a carrier and composes; the declarations live elsewhere.

Rationale and alternatives considered

Why two layers (spec + carrier) rather than one?

A single-carrier design (lift everything to Result<T>, like Vavr) forces every call site in the affected call graph to thread the wrapper through. Groovy 6’s design philosophy explicitly rejects this for null (NullChecker strict mode), monoids (@Reducer), purity (@Pure), and async (async/await with structured try/catch). Extending the same two-layer pattern to errors keeps the codebase coherent:

  • code that just wants the compile-time guarantee uses @Raises and ExceptionChecker — no Result<T> import

  • code that genuinely needs failure as a value uses Result<T>, attempt { }, or @AsResult

  • both layers share the @Raises declaration, so the spec is the single source of truth

Why not adopt Vavr’s Try directly?

Considered. Vavr is the dominant FP library on the JVM today and Try is its canonical error monad. The argument for adoption:

  • mature implementation, large user base, well-documented

  • Try already integrates with Vavr’s Option, Either, Validation, Future ecosystem

  • zero implementation cost

Arguments against, in priority order:

  1. Wrapper viral spread. Once Try<T> enters a call graph, every boundary in that graph carries it. There is no declaration-only path. This is the same objection that drove NullChecker strict mode rather than mandatory Optional<T>.

  2. Groovy dependency footprint. Vavr is ~700KB; bundling it into core is heavier than the spec-layer-only design needs.

  3. Doesn’t compose with the rest of Groovy 6. Vavr’s Try is opaque to PurityChecker, ModifiesChecker, NullChecker, contract @Requires, and the AI-spec story. Native @Raises + ExceptionChecker is machine-readable in the same way.

  4. Vavr is not abandoned in this proposal. io.vavr.control.{Try, Either, Option, Validation} are recognised by name in DO’s allow-list, so existing Vavr-shaped code composes through DO without rewriting.

Why not Java checked exceptions?

Groovy historically and intentionally rejects Java’s compulsory checked-exception enforcement: it interacts poorly with closures / lambdas, generates ceremony in scripts and DSLs, and produces the well-documented "swallow with e.printStackTrace()`" anti-pattern. This GEP does not change that default. `ExceptionChecker is opt-in per file via @TypeChecked(extensions = …); absence of the annotation leaves Groovy’s existing behaviour untouched.

The differences from Java’s checked exceptions, even when ExceptionChecker(strict: true) is on:

  • the rule is configurable per file, not language-wide

  • unchecked exceptions can also be tracked (strict mode)

  • @Requires clauses participate in discharge — a precondition that rules out the throw counts

  • fluent recovery via attempt { } and @AsResult is part of the same proposal, not a separate convenience

  • @Raises is informational in dynamic Groovy code (no enforcement when the file does not enable the checker)

Why @Raises rather than @Throws?

@Throws conflicts with the Java throws keyword visually (and in class-name spelling). Kotlin uses @Throws but specifically for Java interop on Kotlin functions that don’t otherwise advertise checked exceptions — different problem space. @Raises is unambiguous, reads naturally ("this method raises NFE"), and parallels the @Pure/@Modifies/@Associative/@Reducer family in tone.

Why Result<T> rather than Try<T> for the carrier?

Try<T> echoes Vavr/Scala and has FP-tradition recognition. Result<T> aligns with Swift, Kotlin, and Rust and reads as a noun outside FP contexts. The Groovy 6 audience straddles both communities; Result<T> is the lower-friction choice for the non-FP half and loses nothing for the FP half. The annotation @AsResult reads naturally for the same reason.

Why an attempt { } macro rather than a method call?

Result.of { … } already works as a plain static factory. The macro adds two things:

  • block-statement syntaxattempt { … } works as a statement (assigned to var r = …) without the lambda-ceremony of Result.of(() → { … })

  • checker dischargeExceptionChecker recognises the macro form syntactically and discharges any exception that would escape the block, without depending on type inference of the lambda’s return type

The plain Result.of(Supplier) factory remains the runtime primitive.

Why an @AsResult AST transform rather than just attempt { } at method scope?

attempt { … return … } at the top of a method body achieves the same runtime effect. The transform adds:

  • signature-level visibility — readers see @AsResult Result<T> foo(…) on the declaration line and know the body is naturally written as throwing code; no attempt { } indentation in the body

  • return-type enforcement — the transform errors at compile time if the declared return type is not Result<T>, catching a class of mistakes

  • parallel to @TailRecursive — same shape: an annotation that rewrites the body into a form the runtime can execute

Implementation notes

  • @Raises AST transform: lives in the existing groovy.transform package, emits a bytecode throws clause as well as retaining annotation metadata (so both Groovy ExceptionChecker and Java compilers see the declaration).

  • ExceptionChecker implementation: parallels NullChecker’s structure — `GroovyTypeCheckingExtensionSupport.TypeCheckingDSL subclass with afterMethodCall / beforeVisitClass hooks. Reuses the existing flow-sensitive infrastructure that NullChecker already employs for narrowing analysis.

  • Result<T> lives in groovy.util. Sealed-interface + record members rely on Groovy 5+ features that already exist. Same sealed-interface technique used internally by other groovy.util types.

  • attempt { } macro: trivial syntactic rewrite to Result.of(() → body) in the MacroLibGroovyMethods host. ~50 LOC, parallel to other macros in that file.

  • @AsResult AST transform: rewrites the method body to a single try/catch returning Result. ~150 LOC.

  • DO allow-list entry for Result: one line in the MonadicChecker carrier registry. Third-party carrier (Vavr) entries are similarly minimal.

Module layout:

  • groovy-transform: @Raises, @AsResult, transforms

  • groovy-typecheckers: ExceptionChecker

  • groovy-runtime (or groovy.util): Result<T>, Success<T>, Failure<T>

  • groovy-macro-library: attempt { } macro

  • groovy-concurrent (existing): no changes — Awaitable.exceptionally already provides the asynchronous side

Migration and compatibility

  • Pre-Groovy-7 code is unaffected. None of the new types/annotations are referenced by default.

  • The @TypeChecked extension form is opt-in per file, so codebases introducing ExceptionChecker do so incrementally.

  • The bytecode throws clause emitted by @Raises is recognised by javac and IDE inspection tooling — no Groovy dependency required on the consumer side for the propagation contract to be visible.

  • Result<T> is binary-compatible with usage as a plain interface from Java (the sealed-interface permits both Success and Failure as the only implementers; switch-on-type works in Java 21+).

Open questions

  1. Should @Pure imply @Raises({}) (i.e., "throws nothing")? The cleanest story says yes, since a pure method that throws is observably impure to a caller using try/catch for control flow. Counter-argument: lots of legitimately-@Pure methods do throw on invalid input (Integer.parseInt, arithmetic overflow). A new @Total annotation may be cleaner.

  2. Should the inferred-exception set be transitive by default? Calling a method that calls a method that may throw IOException — should the outer method automatically inherit the obligation, or only when explicitly propagated via @Raises? Java’s checked-exception rule says yes (transitive); a Groovy-idiomatic answer might say no (explicit, like `@Pure’s allow-list).

  3. Where does @AsResult interact with @Pure? An @AsResult method catches Throwables and returns them as values — does this make the method @Pure? Probably yes, since the method becomes total over its declared return type.

  4. Carrier naming for Failure’s wrapped error. Should `Failure.error be called error, cause, or throwable? error reads cleanly; cause risks confusion with Throwable’s cause chain; throwable is verbose. Leaning toward error.

  5. Integration with groovy-contracts. A contract violation in @Requires / @Ensures throws AssertionViolation. Should @AsResult catch these, or always propagate? Recommendation: propagate (contracts indicate programmer error, not domain failure).

This GEP composes with several existing or in-flight Groovy features:

  • GEP-23 (DO and MonadicChecker) — landed in Groovy 6.0 (incubating); Result<T> would be a new entry in the same standard carrier registry, alongside the Functional Java and Vavr entries shipped in 6.0.

  • Awaitable.exceptionally (Groovy 6) — provides the asynchronous sibling of Result.recover; Result.toAwaitable() is the bridge.

  • NullChecker (Groovy 6) — provides the template ExceptionChecker parallels. The @Requires discharge mechanism is shared.

References

Update history

21/May/2026: Initial draft.