GEP-24
|
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 plusgroovy.typecheckers.ExceptionChecker, a flow-sensitive type-checking extension that verifies every potentially-thrown exception is either declared or discharged. This is "compile-timeTrywithout the wrapper" — the runtime stays plaintry/catchand stack traces stay legible. -
Carrier layer —
groovy.util.Result<T>(a sealedSuccess/Failurepair), anattempt { … }macro that lifts an exception-throwing block into aResult, an@AsResultAST transform that lifts a whole method body, and an entry inDO’s standard allow-list so `Resultcomposes monadically alongsideOptional,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) andstrictmodes. -
groovy.util.Result<T>— sealedinterfacewithSuccess<T>andFailure<T>record implementations, exposingmap/flatMap/recover/recoverWith/onSuccess/onFailure/getOrElse/getOrThrow/toOptional/toAwaitable. -
attempt { … }— macro in the macro library that lifts a block to aResult<T>. -
groovy.transform.AsResult— AST transform that rewrites a method body into the correspondingtry/catchreturning aResult<T>. -
Resultentered intoMonadicChecker/MonadicShapeChecker/DOcarrier 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 |
|---|---|---|
|
|
|
|
|
(none — algebra moves onto the method) |
|
|
(none — declarations replace lift/unlift) |
|
|
|
Async |
|
|
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
-
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.
-
Provide a runtime
Result<T>carrier with the standardmap/flatMap/recoverAPI, idiomatic for Groovy, free ofµ-tag encodings and free of HKT machinery. -
Make the spec layer and the carrier layer interoperate without ceremony: the same
@Raisesdeclaration informs the checker and the AST transform;Result.toAwaitable()andattempt { }desugar via the same primitives already used byasync { }/Awaitable.exceptionally. -
Recognise existing third-party error carriers (Vavr’s
Try/Either/Validation, fj’sValidation) in `DO’s allow-list by simple name, so codebases that already use them get DO composition for free. -
Provide machine-actionable specs:
@Raisesdeclarations are compiler-enforced and AI-readable in the same way@Pure/@Modifies/@Associative/@Reduceralready are.
Non-goals
-
Re-introducing Java’s checked-exception ceremony at the language level. The spec layer is opt-in per file via the
@TypeCheckedextension; default Groovy code is unchanged. -
Higher-kinded types.
Result<T>is a concrete type. NoMonad<µ>typeclass, no synthesisedpure/return, no carrier-mixing inDO. Same non-goals as GEP-23. -
A
Trylanguage keyword. The block-level lift is theattempt { }macro; the method-level lift is the@AsResultAST transform. The Java keywordtryis unchanged in meaning. -
A new exception hierarchy.
Result.FailurewrapsThrowable; existing exception classes are reused as-is. -
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):
-
@Raises(X)on the called method (Groovy-side declaration). -
Bytecode
throwsclause on the called method (Java methods and Groovy methods declaringthrows X). -
The
@Purefamily — 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). -
(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),ClassCastExceptionin casts, etc., catalogued in a configurable table.
Discharge mechanisms (a hit on any of these removes the obligation):
-
try/catchwhose catch covers the inferred type (or a supertype). The checker handles multi-catch andThrowable/Exception/RuntimeExceptioncatch-all forms. -
ARM / try-with-resources blocks for resources whose
close()declares the exception in question. -
groovy.util.IOGroovyMethods.withCloseable/withStream/ similar closure-scoped resource methods, recognised by name. -
@Raises(X)on the enclosing method — explicit propagation. -
throws Xclause on the enclosing method — same, Java-side spelling. -
A
@Requires({ … })whose top-level conjuncts statically rule out the precondition that would cause the throw (parallel to how@Requires({ x != null })already feedsNullChecker). Example:@Requires({ s ==~ /\d+/ })dischargesNumberFormatExceptionfrom a subsequentInteger.parseInt(s). -
attempt { }— see below. -
@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+recordpermits 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. Thetry/catchlives in one place, in the JDK-trustedgroovy.util.Resultimplementation. -
Resultis not anAwaitable(despite both carrying success-or-failure) —Awaitablesemantics imply scheduling;Resultis synchronous. Useresult.toAwaitable()for explicit interop. -
The catch in
map/flatMapisThrowableexcept forVirtualMachineError,ThreadDeath, andLinkageError, 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:
-
The declared return type of the annotated method must be
Result<T>(or a supertype). The transform errors at compile time otherwise. -
The body’s last expression’s static type must conform to
T. Reuses the existing return-type inference machinery. -
@AsResultis mutually exclusive with@Raises: the method either reifies failures asResultor 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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
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
@RaisesandExceptionChecker— noResult<T>import -
code that genuinely needs failure as a value uses
Result<T>,attempt { }, or@AsResult -
both layers share the
@Raisesdeclaration, 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
-
Tryalready integrates with Vavr’sOption,Either,Validation,Futureecosystem -
zero implementation cost
Arguments against, in priority order:
-
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 droveNullCheckerstrict mode rather than mandatoryOptional<T>. -
Groovy dependency footprint. Vavr is ~700KB; bundling it into core is heavier than the spec-layer-only design needs.
-
Doesn’t compose with the rest of Groovy 6. Vavr’s
Tryis opaque toPurityChecker,ModifiesChecker,NullChecker, contract@Requires, and the AI-spec story. Native@Raises+ExceptionCheckeris machine-readable in the same way. -
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 throughDOwithout 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)
-
@Requiresclauses participate in discharge — a precondition that rules out the throw counts -
fluent recovery via
attempt { }and@AsResultis part of the same proposal, not a separate convenience -
@Raisesis 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 syntax —
attempt { … }works as a statement (assigned tovar r = …) without the lambda-ceremony ofResult.of(() → { … }) -
checker discharge —
ExceptionCheckerrecognises 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; noattempt { }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
-
@RaisesAST transform: lives in the existinggroovy.transformpackage, emits a bytecodethrowsclause as well as retaining annotation metadata (so both GroovyExceptionCheckerand Java compilers see the declaration). -
ExceptionCheckerimplementation: parallelsNullChecker’s structure — `GroovyTypeCheckingExtensionSupport.TypeCheckingDSLsubclass withafterMethodCall/beforeVisitClasshooks. Reuses the existing flow-sensitive infrastructure thatNullCheckeralready employs for narrowing analysis. -
Result<T>lives ingroovy.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 toResult.of(() → body)in theMacroLibGroovyMethodshost. ~50 LOC, parallel to other macros in that file. -
@AsResultAST transform: rewrites the method body to a singletry/catchreturningResult. ~150 LOC. -
DO allow-list entry for
Result: one line in theMonadicCheckercarrier registry. Third-party carrier (Vavr) entries are similarly minimal.
Module layout:
-
groovy-transform:@Raises,@AsResult, transforms -
groovy-typecheckers:ExceptionChecker -
groovy-runtime(orgroovy.util):Result<T>,Success<T>,Failure<T> -
groovy-macro-library:attempt { }macro -
groovy-concurrent(existing): no changes —Awaitable.exceptionallyalready provides the asynchronous side
Migration and compatibility
-
Pre-Groovy-7 code is unaffected. None of the new types/annotations are referenced by default.
-
The
@TypeCheckedextension form is opt-in per file, so codebases introducingExceptionCheckerdo so incrementally. -
The bytecode
throwsclause emitted by@Raisesis recognised byjavacand 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
-
Should
@Pureimply@Raises({})(i.e., "throws nothing")? The cleanest story says yes, since a pure method that throws is observably impure to a caller usingtry/catchfor control flow. Counter-argument: lots of legitimately-@Puremethods do throw on invalid input (Integer.parseInt, arithmetic overflow). A new@Totalannotation may be cleaner. -
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). -
Where does
@AsResultinteract with@Pure? An@AsResultmethod 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. -
Carrier naming for
Failure’s wrapped error.Should `Failure.error be callederror,cause, orthrowable?errorreads cleanly;causerisks confusion with Throwable’scausechain;throwableis verbose. Leaning towarderror. -
Integration with
groovy-contracts. A contract violation in@Requires/@EnsuresthrowsAssertionViolation. Should@AsResultcatch these, or always propagate? Recommendation: propagate (contracts indicate programmer error, not domain failure).
Status of related work
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 ofResult.recover;Result.toAwaitable()is the bridge. -
NullChecker(Groovy 6) — provides the templateExceptionCheckerparallels. The@Requiresdischarge mechanism is shared.
References
-
Groovy 6 release notes — Designed for human and AI reasoning
-
Vavr — the reference design for
Try<T>and related carriers -
FunctionalJava —
fj.data.Validationlineage -
CompletableFuture.exceptionally— JDK precedent for the recover-callback shape -
Joe Duffy — "The Error Model" — Midori’s hybrid exception/Result design that informs the spec-layer / carrier-layer split