1. Introduction

Groovy provides native async/await support, enabling developers to write concurrent code in a sequential, readable style. Rather than dealing with callbacks, CompletableFuture chains, or manual thread management, you express concurrency with two constructs:

  • async { …​ } — start a closure on a background thread, returning an Awaitable

  • await expr — block until an asynchronous result is available

On JDK 21+, async tasks automatically leverage virtual threads for optimal scalability. On JDK 17–20, a cached thread pool is used as a fallback.

The examples throughout this guide use an online multiplayer card game as a running theme — dealing hands, racing for the fastest play, streaming cards, and managing tournament rounds.

2. Getting Started

2.1. Your first async/await

async { …​ } starts work immediately on a background thread and returns an Awaitable. Use await to collect the result:

def deck = ['2♠', '3♥', 'K♦', 'A♣']
def card = async { deck.shuffled()[0] }
println "You drew: ${await card}"

2.2. Exception handling

await unwraps CompletionException and ExecutionException automatically. Standard try/catch works exactly as with synchronous code:

def drawFromEmpty = async {
    throw new IllegalStateException('deck is empty')
}
try {
    await drawFromEmpty
} catch (IllegalStateException e) {
    // Original exception — no CompletionException wrapper
    assert e.message == 'deck is empty'
}

2.3. CompletableFuture interop

await works directly with CompletableFuture, CompletionStage, and Future from Java libraries:

// await works with CompletableFuture from Java libraries
def future = CompletableFuture.supplyAsync { 'A♠' }
assert await(future) == 'A♠'

3. Running Tasks in Parallel

The real power of async/await appears when you need to run several tasks concurrently and coordinate their results.

3.1. Waiting for all: Awaitable.all()

Deal cards to multiple players at the same time and wait for all hands:

// Deal cards to three players concurrently
def deck = [*1..9,'J','Q','K','A'].collectMany { rank ->
    ['♠','♥','♦','♣'].collect { suit -> "$rank$suit" }
}.shuffled()
def index = new AtomicInteger()
def draw5 = { -> def i = index.getAndAdd(5); deck[i..i+4] }

def alice = async { draw5() }
def bob   = async { draw5() }
def carol = async { draw5() }

def (a, b, c) = await Awaitable.all(alice, bob, carol)
assert a.size() == 5 && b.size() == 5 && c.size() == 5

Multi-argument await is syntactic sugar for Awaitable.all():

def a = async { 1 }
def b = async { 2 }
def c = async { 3 }

// Parenthesized multi-arg await (sugar for Awaitable.all):
def results = await(a, b, c)
assert results == [1, 2, 3]

3.2. Racing: Awaitable.any()

Returns the result of the first task to complete — useful for fallback patterns or latency-sensitive code:

// Race two servers — use whichever responds first
def primary  = async { Thread.sleep(200); 'primary-response' }
def fallback = async { 'fallback-response' }

def response = await Awaitable.any(primary, fallback)
assert response == 'fallback-response'

3.3. First success: Awaitable.first()

Like JavaScript’s Promise.any() — returns the first successful result, silently ignoring individual failures. Only fails when every task fails:

// Try multiple sources — use the first that succeeds
def failing    = async { throw new RuntimeException('server down') }
def succeeding = async { 'card-data-from-cache' }

def result = await Awaitable.first(failing, succeeding)
assert result == 'card-data-from-cache'

3.4. Inspecting all outcomes: Awaitable.allSettled()

Waits for all tasks to finish (succeed or fail) without throwing. Returns an AwaitResult list where each entry has success, value, and error fields:

def save1 = async { 42 }
def save2 = async { throw new RuntimeException('db error') }

def results = await Awaitable.allSettled(save1, save2)
assert results[0].success && results[0].value == 42
assert !results[1].success && results[1].error.message == 'db error'

3.5. Combinator summary

Combinator Completes when On failure Use case

Awaitable.all

All succeed

Fails immediately on first failure (fail-fast)

Gather results from independent tasks

Awaitable.allSettled

All complete (success or fail)

Never throws; failures captured in AwaitResult list

Inspect every outcome, e.g. partial-success reporting

Awaitable.any

First task completes (success or failure)

Propagates the first completion’s result or error

Latency-sensitive races, fastest-response wins

Awaitable.first

First task succeeds, or all fail

Throws only when every source fails (aggregate error)

Hedged requests, graceful degradation with fallbacks

4. Generators and Streaming

4.1. Producing values with yield return

An async closure containing yield return becomes a generator — it lazily produces a sequence of values. The generator runs on a background thread and blocks on each yield return until the consumer is ready, providing natural back-pressure:

def dealCards = async {
    def suits = ['♠', '♥', '♦', '♣']
    def ranks = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K']
    for (suit in suits) {
        for (rank in ranks) {
            yield return "$rank$suit"
        }
    }
}
def cards = dealCards.collect()
assert cards.size() == 52
assert cards.first() == 'A♠'
assert cards.last() == 'K♣'

Generators return a standard Iterable, so regular for loops and Groovy collection methods (collect, findAll, take, etc.) work out of the box:

def scores = async {
    for (s in [100, 250, 75]) { yield return s }
}
// Generators return Iterable — regular for and collect work
assert scores.collect { it * 2 } == [200, 500, 150]

5. Channels

Channels provide Go-style inter-task communication. A producer sends values into a channel; a consumer receives them as they arrive. The channel handles synchronization and optional buffering — no shared mutable state needed:

def cardStream = AsyncChannel.create(3)

// Dealer — sends cards concurrently
async {
    for (card in ['A♠', 'K♥', 'Q♦', 'J♣']) {
        await cardStream.send(card)
    }
    cardStream.close()
}

// Player — receives cards as they arrive
def hand = []
for await (card in cardStream) {
    hand << card
}
assert hand == ['A♠', 'K♥', 'Q♦', 'J♣']

Channels support unbuffered (rendezvous, create()) and buffered (create(n)) modes. Sending blocks when the buffer is full; receiving blocks when empty. With virtual threads, this blocking is essentially free.

Since channels implement Iterable, they also work with regular for loops and Groovy collection methods.

6. Channel Composition

AsyncChannel supports composable pipeline operations. Each composition method returns a new channel connected to the source by a background task.

6.1. filter

def source = AsyncChannel.create(10)
def evens = source.filter { it % 2 == 0 }

async {
    (1..10).each { source.send(it) }
    source.close()
}

for await (val in evens) { println val }  // 2, 4, 6, 8, 10

6.2. map

def doubled = source.map { it * 2 }

6.3. Chaining

Operations compose naturally:

def pipeline = source
    .filter { it > 50 }
    .map { it * 2 }

for await (val in pipeline) { process(val) }

6.4. merge

Combines two channels into one, interleaving values as they arrive:

def merged = channelA.merge(channelB)
for await (event in merged) { handle(event) }

6.5. split

Partitions a channel into two based on a predicate:

def (highPriority, normal) = orders.split { it.priority > 5 }

6.6. tap

Sends a copy of each value to a monitoring channel while passing through unchanged:

def monitor = AsyncChannel.create(100)
def output = source.tap(monitor)
// output gets all values; monitor gets copies for logging

7. Channel Select

ChannelSelect waits for the first available value from multiple channels — like Go’s select statement:

import groovy.concurrent.ChannelSelect

def prices = AsyncChannel.create(10)
def alerts = AsyncChannel.create(10)

def sel = ChannelSelect.from(prices, alerts)
def result = await sel.select()
println "Channel ${result.index}: ${result.value}"

8. BroadcastChannel

A BroadcastChannel delivers every value to all subscribers (one-to-many), unlike AsyncChannel which is point-to-point:

import groovy.concurrent.BroadcastChannel

def broadcast = BroadcastChannel.create()
def sub1 = broadcast.subscribe()
def sub2 = broadcast.subscribe()

async {
    broadcast.send('hello')
    broadcast.send('world')
    broadcast.close()
}

// Both subscribers receive both messages
for await (msg in sub1) { println "Sub1: $msg" }
for await (msg in sub2) { println "Sub2: $msg" }

Late subscribers only receive messages sent after they subscribe.

8.1. Flow.Publisher interop

A BroadcastChannel can also be exposed as a java.util.concurrent.Flow.Publisher via asPublisher(). Each call to subscribe(Flow.Subscriber) on the returned publisher creates a new per-subscriber AsyncChannel under the hood and respects standard Reactive Streams demand (request(n)):

def broadcast = BroadcastChannel.create()
def publisher = broadcast.asPublisher()

// Compose with any Reactive Streams operator, or consume with `for await`:
for await (msg in publisher) {
    println "Got: $msg"
}

Backpressure policy. The publisher bridge uses lossless, sender-gated backpressure: send() awaits delivery to every live subscriber, and each per-subscriber channel has a bounded buffer (default 16). A subscriber that never calls request(n) — or requests slowly — will fill its buffer, after which broadcast.send(…​) stalls for all subscribers until that subscriber catches up. In other words, the slowest subscriber controls producer throughput.

This matches the point-to-point semantics of subscribe() (values are neither dropped nor reordered). If you need decoupled per-subscriber policies (drop-newest, drop-oldest, latest-only, or unbounded buffering), wrap the publisher with a Reactive Streams operator of your choice, or use a subscriber that drains promptly with request(Long.MAX_VALUE).

Terminal signals. Per Reactive Streams §1.7, terminal completion is not gated by demand: once the underlying BroadcastChannel is closed and the per-subscriber buffer is drained, the subscriber receives onComplete even if it has no outstanding request(n). Likewise, onError for a spec-violating request(n ⇐ 0) (§3.9) is signalled through the same single-threaded delivery path as onNext / onComplete, preserving §1.3 signal serialization; subscribers do not see those signals racing on the caller’s thread.

9. Deferred Cleanup with defer

The defer keyword schedules a cleanup action to run when the enclosing async closure completes, regardless of success or failure. Multiple deferred actions execute in LIFO order — last registered, first to run — making it natural to pair resource acquisition with cleanup:

def log = []
def task = async {
    log << 'open connection'
    defer { log << 'close connection' }
    log << 'open transaction'
    defer { log << 'close transaction' }
    log << 'save game state'
    'saved'
}
assert await(task) == 'saved'
// Deferred actions run in LIFO order — last registered, first to run
assert log == ['open connection', 'open transaction', 'save game state',
               'close transaction', 'close connection']

Deferred actions always run, even when an exception occurs:

def cleaned = false
def task = async {
    defer { cleaned = true }
    throw new RuntimeException('save failed')
}
try {
    await task
} catch (RuntimeException e) {
    assert e.message == 'save failed'
}
// Deferred actions run even when an exception occurs
assert cleaned

If a deferred action returns an Awaitable or Future, the result is awaited before the next deferred action runs, ensuring orderly cleanup of asynchronous resources.

10. Structured Concurrency

Structured concurrency ensures that concurrent tasks have clear ownership and bounded lifetimes — no orphaned background work, no silent failures leaking across your application. This idea is gaining momentum across the industry (Java’s JEP 453, Go’s errgroup, Kotlin’s coroutine scopes) because it makes concurrent code easier to reason about, test, and debug. Groovy’s AsyncScope provides these guarantees today, even on JDK versions before Project Loom’s StructuredTaskScope ships as a final API.

AsyncScope binds the lifetime of child tasks to a scope. When the scope exits, all children are guaranteed complete (or cancelled). This prevents orphaned tasks and silent failures:

// Run a tournament round — all tables play concurrently
def results = AsyncScope.withScope { scope ->
    def table1 = scope.async { [winner: 'Alice',  score: 320] }
    def table2 = scope.async { [winner: 'Bob',    score: 280] }
    def table3 = scope.async { [winner: 'Carol',  score: 410] }
    [await(table1), await(table2), await(table3)]
}
// All tables guaranteed complete when withScope returns
assert results.size() == 3
assert results.max { it.score }.winner == 'Carol'

By default, the scope uses fail-fast semantics — if any child fails, all siblings are cancelled immediately:

try {
    AsyncScope.withScope { scope ->
        scope.async { Thread.sleep(5000); 'still playing' }
        scope.async { throw new RuntimeException('player disconnected') }
    }
} catch (RuntimeException e) {
    // First failure cancels all siblings and propagates
    assert e.message == 'player disconnected'
}

The scope waits for every child to finish, even without explicit await calls:

def completed = new AtomicInteger(0)
AsyncScope.withScope { scope ->
    3.times { scope.async { Thread.sleep(50); completed.incrementAndGet() } }
}
// All children have completed — even without explicit await
assert completed.get() == 3

On JDK 25+, scope tracking uses ScopedValue for optimal virtual thread performance (no per-thread storage, automatic inheritance). On JDK 17–24, a ThreadLocal fallback is used transparently.

11. Advanced Topics

11.1. Consuming with for await

for await iterates over any async source. For generators and plain collections, it works identically to a regular for loop:

def topCards = async {
    for (card in ['A♠', 'K♥', 'Q♦']) {
        yield return card
    }
}
def hand = []
for await (card in topCards) {
    hand << card
}
assert hand == ['A♠', 'K♥', 'Q♦']

The key value of for await is with reactive types (Reactor Flux, RxJava Observable, java.util.concurrent.Flow.Publisher) where it automatically converts the source to a blocking iterable via the adapter SPI. Without for await, you would need to call the conversion manually (e.g., flux.toIterable()). For generators and plain collections, a regular for loop works identically.

The JDK’s Flow.Publisher is supported out of the box — no adapter module needed. This means streams exposed by built-in sources such as Agent.changes() (see Agent) and BroadcastChannel.asPublisher() (see BroadcastChannel), or any third-party publisher using the JDK interfaces, compose directly with for await and await:

import groovy.concurrent.Agent

def agent = Agent.create(0)
try {
    async {
        3.times { agent.send { it + 1 } }
        agent.shutdown()
    }
    for await (v in agent.changes()) {
        println "value is now $v"
    }
} finally {
    agent.shutdown()
}

11.2. Timeouts and Delays

Apply a deadline to any task. If it doesn’t complete in time, a TimeoutException is thrown:

def slowPlayer = async { Thread.sleep(5000); 'finally played' }
try {
    await Awaitable.orTimeoutMillis(slowPlayer, 100)
} catch (TimeoutException e) {
    // Player took too long — turn forfeited
    assert true
}

Or use a fallback value instead of throwing:

def slowPlayer = async { Thread.sleep(5000); 'deliberate move' }
def move = await Awaitable.completeOnTimeoutMillis(slowPlayer, 'auto-pass', 100)
assert move == 'auto-pass'

Awaitable.delay() pauses without blocking a thread:

long start = System.currentTimeMillis()
await Awaitable.delay(100)   // pause without blocking a thread
assert System.currentTimeMillis() - start >= 90

11.3. Framework Adapters

await natively understands Awaitable, CompletableFuture, CompletionStage, and Future. for await natively understands java.util.concurrent.Flow.Publisher in addition to regular Iterable sources. Third-party async types can be supported by implementing the AwaitableAdapter SPI and registering it via META-INF/services/groovy.concurrent.AwaitableAdapter.

Drop-in adapter modules are provided for:

  • groovy-reactorawait on Mono, for await over Flux

  • groovy-rxjavaawait on Single/Maybe/Completable, for await over Observable/Flowable

For example, without the adapter you must manually convert RxJava types:

// Without groovy-rxjava — manual conversion
def result = Single.just('hello').toCompletionStage().toCompletableFuture().join()

With groovy-rxjava on the classpath, the conversion is transparent:

// With groovy-rxjava — adapter handles the plumbing
def result = await Awaitable.from(Single.just('hello'))

11.4. Executor Configuration

By default, async uses:

  • JDK 21+: a virtual-thread-per-task executor

  • JDK 17–20: a cached daemon thread pool (max 256 threads, configurable via groovy.async.parallelism system property)

You can override the executor:

import org.apache.groovy.runtime.async.AsyncSupport
import java.util.concurrent.Executors

AsyncSupport.setExecutor(Executors.newFixedThreadPool(4))
AsyncSupport.resetExecutor()  // restore default

11.5. Integration with JDK Classes

await works with any JDK API that returns a CompletableFuture, CompletionStage, or Future. This means you can combine process execution, asynchronous file I/O, and HTTP calls in a single async block — all running concurrently:

// Task 1: Run a process and await its completion
def proc = async {
    def p = echoCmd.execute() // echo Hello from Groovy
    await p.onExit()
    p.text.trim()
}

// Task 2: Read a file asynchronously
def fileContent = async {
    await etcPassword.textAsync
}

// Task 3: Fetch a web page using Groovy's HttpBuilder
def webContent = async {
    def http = HttpBuilder.http { baseUri bankUrl }
    http.get('/withdraw/100').body
}

// All three tasks run concurrently — collect results
var (echo, file, html) = await proc, fileContent, webContent
assert echo == 'Hello from Groovy'
assert file =~ /sEcrEt/
assert html == '<html>SUCCESS</html>'

Task 3 uses Groovy’s HttpBuilder (from groovy-http-builder), which wraps JDK HttpClient with a concise DSL. For lower-level control, you can also use HttpClient directly:

import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse

def webContent = async {
    def client = HttpClient.newHttpClient()
    def req = HttpRequest.newBuilder()
        .uri(URI.create('https://api.example.com/data'))
        .build()
    def resp = await client.sendAsync(req, HttpResponse.BodyHandlers.ofString())
    resp.body()
}

Key JDK classes that work with await out of the box:

JDK class Method returning async result

Process

onExit()CompletableFuture<Process>

HttpClient

sendAsync(…​)CompletableFuture<HttpResponse<T>>

Path (via groovy-nio)

getTextAsync(), getBytesAsync(), writeAsync(…​)CompletableFuture

AsynchronousFileChannel

read(…​), write(…​), lock(…​)Future<Integer> / Future<FileLock>

CompletableFuture

supplyAsync(…​), allOf(…​), anyOf(…​)CompletableFuture<T>

11.6. Async Closure Factories

Because async { …​ } is just an expression that returns an Awaitable, you can wrap it in an ordinary method or closure to create a reusable factory:

// A method that returns an async task — a simple factory
def dealCard(List deck) {
    async { deck.shuffled()[0] }
}

def numPlayers = 3
// each player gets a card from their own deck
def cards = (1..numPlayers).collect { dealCard(['A♠', 'K♥', 'Q♦', 'J♣']) }

def hands = await Awaitable.all(*cards)
assert hands.size() == 3
assert hands.every { it in ['A♠', 'K♥', 'Q♦', 'J♣'] }

This is a natural way to build reusable async building blocks without any special framework support.

12. Best Practices

12.1. Prefer returning values over mutating shared state

Async closures run on separate threads. Mutating shared variables from multiple closures is a race condition:

// UNSAFE — shared mutation without synchronization
var count = 0
def tasks = (1..100).collect { async { count++ } }
tasks.each { await it }
// count may not be 100!

Instead, return values and collect results:

// SAFE — no shared mutation
def tasks = (1..100).collect { n -> async { n } }
def results = await Awaitable.all(*tasks)
assert results.sum() == 5050

When shared mutable state is unavoidable, use the appropriate concurrency-aware type, e.g. AtomicInteger for a shared counter, or thread-safe types from java.util.concurrent for players concurrently drawing cards from a shared deck.

12.2. Choosing the right tool

Feature Use when…​

async/await

You have sequential steps involving I/O or blocking work and want code that reads top-to-bottom.

Awaitable.all / any / first

You need to launch independent tasks and collect all results, race them, or take the first success.

yield return / for await

You’re producing or consuming a stream of values — paginated APIs, card dealing, log tailing.

defer

You acquire resources at different points and want guaranteed cleanup without nested try/finally.

AsyncChannel

Two or more tasks need to communicate — producer/consumer, fan-out/fan-in, or hand-off.

AsyncScope

You want child task lifetimes tied to a scope with automatic cancellation on failure.

Framework adapters

You’re already using Reactor or RxJava and want await / for await to work with their types.

13. Quick Reference

Construct Description

async { …​ }

Start a closure on a background thread. Returns Awaitable (or Iterable for generators).

await expr

Block until the result is available. Rethrows the original exception.

await(a, b, c)

Wait for all — syntactic sugar for await Awaitable.all(a, b, c).

yield return expr

Produce a value from an async generator. Consumer blocks until ready.

for await (x in src)

Iterate over an async source (generator, channel, Flux, Observable, etc.).

defer expr

Schedule a cleanup action (LIFO order) inside an async closure.

AsyncChannel.create(n)

Create a buffered (or unbuffered) channel for inter-task communication.

AsyncScope.withScope { …​ }

Structured concurrency — all children complete (or are cancelled) on scope exit.

Awaitable.orTimeoutMillis

Apply a deadline. Throws TimeoutException if the task exceeds it.

Awaitable.completeOnTimeoutMillis

Apply a deadline with a fallback value instead of throwing.

Awaitable.delay(ms)

Non-blocking pause.

All keywords (async, await, defer) are contextual — they can still be used as variable or method names in existing code.

14. Monadic comprehensions (Incubating)

The groovy-macro-library module provides DO, a comprehension macro that rewrites a sequence of name in source generators followed by a body into a chain of bind operations on the carrier type. It gives Scala-style for-comprehension / Haskell-style do-notation ergonomics to any type with monadic shape — Optional, Stream, CompletableFuture, Groovy’s Awaitable and DataflowVariable, and user-defined types that opt in.

DO is incubating (since Groovy 6.0) and may change. The macro is compile-time only; the generated code calls the Comprehensions runtime support in core, so a program using DO needs only the core groovy jar at runtime — groovy-macro-library is a compile-time dependency.
def result = DO(a in Optional.of(2),
                b in Optional.of(3)) {
    Optional.of(a + b)
}
assert result.get() == 5

14.1. Desugaring

Every generator becomes a bind; the body is the innermost closure body and must itself yield a value of the carrier type (the do-notation rule — there is no implicit lifting in this version). The example above expands to:

Comprehensions.bind(Optional.of(2)) { a ->
    Comprehensions.bind(Optional.of(3)) { b ->
        Optional.of(a + b)
    }
}

Because the macro expands before type information is available, it does not emit a carrier-specific method name directly. Comprehensions.bind resolves the right operation at runtime; under @CompileStatic the groovy.typecheckers.MonadicChecker extension supplies the static types (see Static type checking).

For all but the most trivial uses, DO under @CompileStatic or @TypeChecked requires the MonadicChecker extension (@CompileStatic(extensions = 'groovy.typecheckers.MonadicChecker')). The runtime dispatcher signature Comprehensions.bind(Object, Closure):Object erases the carrier’s element type and the comprehension’s result type, so without the extension each generator’s bound name and the whole DO expression both fall through to Object — which the static type checker will reject as soon as the body or downstream code does anything type-specific with either. See Static type checking.

A name bound by an earlier generator is in scope in the source expression of every later generator and in the body:

def result = DO(a in Optional.of(10),
                b in Optional.of(a * 2)) {   // b's source depends on a
    Optional.of(a + b)
}
assert result.get() == 30

Short-circuiting is delivered by the carrier, not by the macro: an empty or failed carrier simply propagates and the body is never evaluated.

def result = DO(a in Optional.empty(),
                b in Optional.of(3)) {
    Optional.of(b)            // never reached
}
assert result.isEmpty()

14.2. Participating carriers

A type participates as a carrier when, in order, the first match winning:

  1. it is on the standard allow-list — java.util.Optional, java.util.stream.Stream, java.util.concurrent.CompletionStage (covering CompletableFuture), and groovy.concurrent.Awaitable (covering DataflowVariable);

  2. it is structural — it has a single-argument flatMap (and, for the map role, map);

  3. it is annotated @groovy.transform.Monadic, optionally declaring non-conventional method names.

The allow-list also recognises common Functional Java carriers by name — fj.data.Option, fj.data.List, fj.data.Stream, fj.data.Validation and fj.P1 — using that library’s bind/map convention. Groovy takes no dependency on Functional Java; the names are matched reflectively, and the generator closure is coerced to fj.F automatically. fj.data.Either is not directly monadic in Functional Java (bind lives on its .right()/.left() projections) and is not a carrier; use a projection explicitly.

Awaitable and DataflowVariable bind via thenCompose; their then method is the map operation, not bind. DO over Awaitable therefore composes asynchronous values without an imperative await at each step:
import groovy.concurrent.Awaitable
import static org.apache.groovy.runtime.async.AsyncSupport.await

def total = DO(a in Awaitable.of(2),
               b in Awaitable.of(40)) {
    Awaitable.of(a + b)
}
assert await(total) == 42

Stream yields the usual cartesian composition:

import java.util.stream.Stream

def pairs = DO(x in Stream.of(1, 2),
               y in Stream.of('a', 'b')) {
    Stream.of("$x$y".toString())
}
assert pairs.toList() == ['1a', '1b', '2a', '2b']

A user type opts in with @Monadic, which may name a non-conventional bind and map method:

import groovy.transform.Monadic
import java.util.function.Function

@Monadic(bind = 'chain', map = 'transform', unit = 'of')
class Result {
    final Object value
    Result(Object value) { this.value = value }
    static Result of(value) { new Result(value) }     // unit: not used by DO, declared for law tooling
    Result chain(Function f) { (Result) f.apply(value) }
    Result transform(Function f) { new Result(f.apply(value)) }
    boolean equals(o) { o instanceof Result && value == o.value }   // results compare by value...
    int hashCode() { value == null ? 0 : value.hashCode() }
}

def r = DO(a in Result.of(3),
           b in Result.of(4)) {
    Result.of(a * b)
}
assert r == new Result(12)                            // ...so == means "same wrapped value"

The monad laws (left identity, right identity, associativity) are not enforced by the compiler; as with @Reducer/@Associative, lawful behaviour is the participating type’s responsibility. Because a comprehension composes monadic values, a data-style carrier should provide structural equals/hashCode (Groovy’s == dispatches to equals) — otherwise results compare by identity and laws such as right identity and functor identity silently fail to hold. This does not apply to effectful carriers like Stream, CompletableFuture and Awaitable, whose laws hold only up to observational equivalence. A carrier may optionally name its unit (of/pure) factory via @Monadic(unit = '…​'); DO does not use it — the body lifts explicitly — but it lets law-deriving tooling synthesize the unit-dependent laws (left and right identity).

14.3. Static type checking

Activate the MonadicChecker type-checking extension to use DO under @CompileStatic or @TypeChecked. It types each generator’s bound name as the carrier’s element type (so the body type-checks), restores the comprehension’s result type, and rejects a non-participating carrier with a compile error naming the type and the missing shape:

import groovy.transform.CompileStatic

@CompileStatic(extensions = 'groovy.typecheckers.MonadicChecker')
class Calc {
    static int sum() {
        DO(a in Optional.of(2),
           b in Optional.of(3)) {
            Optional.of(a + b)
        }.get()
    }
}
assert Calc.sum() == 5
The typical symptom of forgetting the extension is a static type checking error reporting that some operation cannot be found on Object — either inside the body (a generator’s bound name has erased to Object) or on the DO expression itself (the result has erased to Object). Adding extensions = 'groovy.typecheckers.MonadicChecker' to the @CompileStatic/@TypeChecked annotation resolves it.

14.3.1. Lint for hand-written chains: MonadicShapeChecker

If you write flatMap / map chains by hand (without DO) over the same participating carriers, the sister MonadicShapeChecker extension provides a compile-time lint:

@TypeChecked(extensions = 'groovy.typecheckers.MonadicShapeChecker')

It catches the two classic shape mistakes — a bind (i.e. `flatMap`) closure that returns a non-carrier value, and a map closure that returns the same carrier where flatMap was meant. A strict option tightens what is flagged; the default lenient mode reports only high-confidence violations. Both checkers are documented from the type-checker side in the Built-in auxiliary type checkers chapter.

14.4. When to use DO

DO is a value-composition notation. It complements, rather than competes with, the concurrency constructs: use imperative async/await when code reads as a sequence of dependent steps you intend to run now (see Prefer returning values over mutating shared state and Choosing the right tool); reach for DO when the composed value is the deliverable — an Awaitable to combine further, an Optional/validation result, a parser result, or a custom Result type — and you want one uniform notation across carriers.

This version is deliberately narrow. The body must yield a carrier value (no implicit pure/unit lifting); there are no if guard clauses; and each DO works over a single carrier (nest DO blocks for more). break/continue are not valid in the body, and return follows the standard closure rule. These are the same closure-body constraints the @Parallel for-loop transform documents.