zena:async

zena
import {…} from 'zena:async';

Asynchronous programming primitives: futures, completers, structured concurrency with task groups, cancellation scopes, and microtask scheduling.

Future<T> represents a value delivered asynchronously, produced through a Completer<T>. Callbacks attached via then run asynchronously through the FIFO microtask queue. TaskGroup coordinates concurrent tasks with structured lifetime, error propagation, and cancellation; CancelScope is the read-only view of a group's cancellation that the work inside it sees.

Examples ​

zena
import { Future, Completer, scheduleMicrotask } from 'zena:async';

let completer = new Completer<String>();
scheduleMicrotask(() => {
  completer.complete('done');
});

completer.future.then((value: String): void => {
  // handle completed value
});

Classes

CancelScope

zena
class CancelScope

A cancellation scope: a level-triggered cancelled flag and a parent link. Cancelling a scope marks it and every descendant, and every checkpoint in a cancelled scope raises — however many already have. Async frames bind to the current scope at creation and test it at each checkpoint; sync code that wants to stop early polls currentScope() explicitly.

Seen from inside the work, a scope is read-only: isCancelled is its only public member. A TaskGroup creates its scope and holds the cancel for it, so code can cancel work only through a group whose creator handed it the reference (docs/design/cancellation.md, "The cancel capability").

Properties
zena
isCancelled: boolean { get; }
#

TaskGroup

zena
class TaskGroup

A group of async calls that are cancelled and awaited together.

spawn starts a call and makes it a member: the group holds one CancelScope, each member's frame binds to that scope, and the group watches each member's future. cancel cancels every member; one member's failure cancels the rest; and join gives a future that completes once every member has settled — failing with the first member failure, if there was one. A member that ends cancelled is a recorded outcome, not an error.

A group is the only way to create a scope and the only holder of its cancel. Work inside the group sees the scope read-only through currentScope(), so code can cancel the group's work only if the group's creator passed it the group.

Constructors
zena
new()
#

A group whose scope is a child of the scope current at construction, so cancelling an ancestor cancels the group's members too.

Properties
zena
isCancelled: boolean { get; }
#
Methods
zena
static detached(): TaskGroup
#

A group whose scope has no parent, wherever it is created: cancelling any other scope never reaches its members, and only the group's holder can cancel them. This is the group for work that must outlive its callers — a cache's shared fills, for example — where new TaskGroup() would quietly parent to whichever scope happens to be current at construction.

zena
cancel(): void
#

Cancels every member (and work they spawned): marks the group's scope, so each member raises at its next checkpoint. Reaching this call is what opens the compiler's whole-program cancellation gate; it is the one operation that marks a scope.

zena
spawn<T>(body: () => Future<T>): Future<T>
#

Starts body as a member of the group. The call begins here, synchronously, with the group's scope installed as current, so the new frame binds to it. The returned future is an ordinary future — anyone may await it — and the group watches it either way.

zena
join(): Future<void>
#

A future that completes once every member has settled — failing with the first member failure, if there was one. Joins while members are outstanding share one future. A join with nothing outstanding gets a fresh, already-settled future: settling and keeping the shared completer instead would break a group that spawns again after going idle, whose next drain would settle it a second time.

zena
static race<A>(bodies: Array<() => Future<A>>): Future<A>
#

Runs every body as a member of a fresh group and gives the first settled outcome — value or failure — cancelling the rest. This is the loser-cancelling form of Future.race: race over already-started futures ignores its losers, because it does not own them, while this form starts the candidates itself and so may cancel the ones that lost. If cancellation instead arrives from outside — an ancestor scope cancels the group before any candidate settles — the returned future completes cancelled.

An empty array is refused: its future could never settle.

FutureClaim

zena
class FutureClaim<T> implements Disposable
Implements Disposable

A future plus a counted claim on the work producing it. Work spawned on behalf of several consumers should keep running while any of them still wants the result and stop when none does; holding an FutureClaim is declaring that want. Releasing the handle — using, :dispose, or an explicit release() — gives the claim up, and when the last claim is gone the work's scope is cancelled, one queue turn later.

Sharing is an explicit split(): the count goes up before the second handle exists, so claims never pass through zero during a handoff. Releasing is per-handle and idempotent.

The bare future stays an ordinary value. Holding it confers awaiting and nothing else: if every claim is released, a bystander mid-await gets the cancellation raise at its checkpoint. Awaiting without holding a claim is awaiting at the claim holders' pleasure — which is the honest version of the contract, and why nothing on Future itself can cancel.

Constructors
zena
new(interest: Interest)
#
Properties
zena
future: Future<T>
#
zena
isReleased: boolean { get; }
#

Whether this handle still holds its claim.

Methods
zena
static spawn<A>(body: () => Future<A>): FutureClaim<A>
#

Starts body as owned work: the call runs as the one member of a new group — whose scope is a child of the current scope, so enclosing cancellation still reaches it — and the returned handle carries the one initial claim. Work that must not inherit the caller's cancellation runs the spawn inside a member of TaskGroup.detached().

zena
split(): FutureClaim<T>
#

A second claim on the same work. Throws if this handle was already released — a released handle has nothing to share.

zena
release(): void
#

Gives this handle's claim up. Releasing the last claim cancels the work's scope one queue turn later. Idempotent, per handle.

Future

zena
class Future<T>
Re-exported from zena:async/future.zena

A value that will be available later.

T is scoped (docs/design/owner-futures.md): a future may hold an owner or a borrow, and the instance carries that restriction, so a Future<Own<R>> moves like the owner and its await consumes it. At such a T the future has one consumer: reading the value takes it, leaving the slot empty, and a second waiter is refused. At a first-class T the value stays readable by every waiter.

Properties
zena
isCompleted: boolean { get; }
#

Whether this future has settled. The one synchronous observation kept public: runtime infrastructure (blockOn) needs it, and unlike the settled STATE it does not let a result channel branch on an outcome it should only learn by subscribing.

Methods
zena
async then<R>(onValue: (value: T) => R, onError: (error: Error) => R | null = null): Future<Awaited<R>>
#

A future of this future's value, transformed: once this settles with a value, the derived future settles with onValue(value) — and a callback result that is itself a future CHAINS, the derived future settling the way the inner one does (Awaited<R> is the type of that flattening, one level). A failure propagates untransformed unless onError is given, whose result then RECOVERS the chain; a callback that throws fails the derived future with what it threw, and is NOT caught by its own onError, which handles only this future's failure. Either callback runs from the microtask queue, never synchronously here.

Cancellation is not an error and has no callback: if this future completes cancelled, the derived future completes cancelled too, and neither callback runs. Reacting to a cancellation belongs to the scope that caused it, or to a finalizer (cancel, using) in the work being unwound — never to a result channel, where a handler could swallow the unwind (docs/design/cancellation.md).

zena
static of<A>(value: A): Future<A>
#

An already-resolved future. A is scoped, so the value may hold an owner: a stream's refused write resolves with the element it hands back.

zena
static failed<A>(error: Error): Future<A>
#

An already-failed future.

zena
static all<A>(futures: Array<Future<A>>): Future<Array<A>>
#

A future of every input's value, in input order regardless of the order they settle in — or of the first failure to arrive.

The failure is reported as soon as it happens, without waiting for the other inputs: once one has failed no combined value can exist, so waiting could only delay the answer, and would hang outright if another input never settles. Inputs that settle after that are ignored — they lost a race that was already decided, which is not an error.

With no inputs the result is an empty array. It still settles through the microtask queue like everything else (the always-async rule), so all never runs a continuation before its caller returns.

Futures are eager, so the inputs are already running when they get here: this waits for them, it does not start them.

zena
static race<A>(futures: Array<Future<A>>): Future<A>
#

A future settling as the first input to settle does — with its value, or with its failure. A failure wins a race exactly the way a value does; use it when the first answer is the answer, and note that the losers keep running (nothing here cancels them; that waits on cancellation).

Throws on an empty array rather than returning a future. A race with nothing in it can never settle, and a future that never settles surfaces much later as a deadlock report naming the awaiting code, so the mistake is refused where it was made.

zena
static async allSettled<T>(futures: Array<T>): ScopedFrom<T, Future<Array<Result<Awaited<T>, Error>>>>
#

A future of every input's outcome, value or failure alike, in input order. It settles only once every input has — there is no early exit to take, which is what lets this be ordinary async code: awaiting the inputs one at a time still finishes when the slowest input does, because the inputs are already running and a later input settles whether or not anything is awaiting it yet. (all and race answer BEFORE some inputs settle, so they need concurrent observation and stay subscription-shaped.)

T is scoped, so the inputs may be scoped futures — a literal at the call site, ownership.md §"Scoped containers and extent nesting" — as well as ordinary ones, and this is the one combinator that may take them: every input is awaited before the result settles, so none is left running with its borrow. The result is scoped exactly when an input is (ScopedFrom); over ordinary futures it is the ordinary future it always was.

Cancellation is not a result (no (false, e) is made for it, and Result has no third member): catch cannot observe the cancellation channel, so awaiting a cancelled input unwinds this frame and the combined future completes cancelled — being downstream of cancelled work is inside the blast radius (docs/design/cancellation.md).

zena
static any<A>(futures: Array<Future<A>>): Future<A>
#

A future of the first input to settle with a value. Failures do not win: a failed input just leaves the race, and only when every input has failed does the result fail — with an AggregateError carrying each input's failure in input order.

The mirror image of all: all needs every value so one failure decides it, any needs one value so only total failure does. Throws on an empty array for race's reason — a future that can never settle surfaces much later, as a deadlock report far from the mistake.

AnyCompleter

zena
class AnyCompleter
Re-exported from zena:async/future.zena

A completer with its payload type erased.

This exists so a collection can hold completers for different T — which is what zena:js's handle registry is. A base class rather than an interface, deliberately and not by preference: a value held as an interface could not be tested or downcast back to its concrete type when this was written (x is Plain answered false through an interface reference, for generic and non-generic classes alike). That was fixed on 2026-08-13, so the base class is now a workaround that could be revisited. Through a base class it works, and it distinguishes specializations exactly: a Completer<String> held as AnyCompleter tests true for Completer<String> and false for Completer<i32>, Completer<f64>, and Completer<SomeOtherClass> alike.

That is what makes the registry need no type tag of its own. The completer's own specialized type is the tag, checked by the same ref.cast the language already emits.

fail is declared here because failing needs no payload: a caller that only knows the erased completer can still reject it.

Methods
zena
fail(_error: Error): void
#

Rejects the underlying future. Overridden by Completer<T>.

Completer

zena
class Completer<T> extends AnyCompleter
Extends AnyCompleter
Re-exported from zena:async/future.zena

The boundary object: everything external — host I/O, timers, test harnesses — completes futures through a Completer.

T is scoped (owner-futures.md §"Completer"), so a completer may settle a future of an owner. Such a future has one consumer, so completer.future cannot be read off it; make the pair with Completer.create instead.

Properties
zena
future
#
Methods
zena
static create<A>(): inline (Completer<A>, Future<A>)
#

A completer and its future. At a restricted T, such as a future of an owner, this is how the pair is made: the future belongs to its consumer, and the completer keeps a reference that only settles it.

zena
complete(value: T): void
#
zena
fail(error: Error): void
#
zena
tryComplete(value: T): inline (true, _) | inline (false, T)
#

Settles with a value, or hands it back, (false, value), when someone else settled first — not an error for a caller racing on purpose.

zena
tryFail(error: Error): boolean
#

Settles with a failure, reporting whether it won.

DeferredFuture

zena
class DeferredFuture<T> extends Future<T> implements Disposable
Extends Future<T>
Implements Disposable
Re-exported from zena:async/future.zena

A future whose work starts only once someone waits for it.

produce runs on the first subscription — an await, a then, a combinator — and the future it returns is adopted: this one settles as that one does. Nothing runs for a future nobody looks at. For a value that costs something to fetch and may never be wanted: a canonical future the host writes, whose read holds a waitable on the driver — and the task that owns it — until the host writes the end, and which every request.new and response.new hands back beside the resource whether or not the program cares.

Its own class rather than a field on Future: a future is allocated per await-able call, and the deferred kind is rare.

T is scoped, as Future's is: a lifted WIT future may carry an owner, and dropping one nobody subscribed to runs drop, the host's readable-end drop, so the host releases what it wrote (owner-futures.md §"Releasing what nobody took").

Constructors
zena
new(produce: () => Future<T>, drop: () => void | null = null)
#
8 inherited members
From Future
Properties
zena
isCompleted: boolean { get; }
#

Whether this future has settled. The one synchronous observation kept public: runtime infrastructure (blockOn) needs it, and unlike the settled STATE it does not let a result channel branch on an outcome it should only learn by subscribing.

Methods
zena
async then<R>(onValue: (value: T) => R, onError: (error: Error) => R | null = null): Future<Awaited<R>>
#

A future of this future's value, transformed: once this settles with a value, the derived future settles with onValue(value) — and a callback result that is itself a future CHAINS, the derived future settling the way the inner one does (Awaited<R> is the type of that flattening, one level). A failure propagates untransformed unless onError is given, whose result then RECOVERS the chain; a callback that throws fails the derived future with what it threw, and is NOT caught by its own onError, which handles only this future's failure. Either callback runs from the microtask queue, never synchronously here.

Cancellation is not an error and has no callback: if this future completes cancelled, the derived future completes cancelled too, and neither callback runs. Reacting to a cancellation belongs to the scope that caused it, or to a finalizer (cancel, using) in the work being unwound — never to a result channel, where a handler could swallow the unwind (docs/design/cancellation.md).

zena
static of<A>(value: A): Future<A>
#

An already-resolved future. A is scoped, so the value may hold an owner: a stream's refused write resolves with the element it hands back.

zena
static failed<A>(error: Error): Future<A>
#

An already-failed future.

zena
static all<A>(futures: Array<Future<A>>): Future<Array<A>>
#

A future of every input's value, in input order regardless of the order they settle in — or of the first failure to arrive.

The failure is reported as soon as it happens, without waiting for the other inputs: once one has failed no combined value can exist, so waiting could only delay the answer, and would hang outright if another input never settles. Inputs that settle after that are ignored — they lost a race that was already decided, which is not an error.

With no inputs the result is an empty array. It still settles through the microtask queue like everything else (the always-async rule), so all never runs a continuation before its caller returns.

Futures are eager, so the inputs are already running when they get here: this waits for them, it does not start them.

zena
static race<A>(futures: Array<Future<A>>): Future<A>
#

A future settling as the first input to settle does — with its value, or with its failure. A failure wins a race exactly the way a value does; use it when the first answer is the answer, and note that the losers keep running (nothing here cancels them; that waits on cancellation).

Throws on an empty array rather than returning a future. A race with nothing in it can never settle, and a future that never settles surfaces much later as a deadlock report naming the awaiting code, so the mistake is refused where it was made.

zena
static async allSettled<T>(futures: Array<T>): ScopedFrom<T, Future<Array<Result<Awaited<T>, Error>>>>
#

A future of every input's outcome, value or failure alike, in input order. It settles only once every input has — there is no early exit to take, which is what lets this be ordinary async code: awaiting the inputs one at a time still finishes when the slowest input does, because the inputs are already running and a later input settles whether or not anything is awaiting it yet. (all and race answer BEFORE some inputs settle, so they need concurrent observation and stay subscription-shaped.)

T is scoped, so the inputs may be scoped futures — a literal at the call site, ownership.md §"Scoped containers and extent nesting" — as well as ordinary ones, and this is the one combinator that may take them: every input is awaited before the result settles, so none is left running with its borrow. The result is scoped exactly when an input is (ScopedFrom); over ordinary futures it is the ordinary future it always was.

Cancellation is not a result (no (false, e) is made for it, and Result has no third member): catch cannot observe the cancellation channel, so awaiting a cancelled input unwinds this frame and the combined future completes cancelled — being downstream of cancelled work is inside the blast radius (docs/design/cancellation.md).

zena
static any<A>(futures: Array<Future<A>>): Future<A>
#

A future of the first input to settle with a value. Failures do not win: a failed input just leaves the race, and only when every input has failed does the result fail — with an AggregateError carrying each input's failure in input order.

The mirror image of all: all needs every value so one failure decides it, any needs one value so only total failure does. Throws on an empty array for race's reason — a future that can never settle surfaces much later, as a deadlock report far from the mistake.

AggregateError

zena
class AggregateError extends Error
Extends Error
Re-exported from zena:async/future.zena

The failure of every input at once: how Future.any reports that no input produced a value, with each input's failure kept in input order.

Constructors
zena
new()
#
Properties
zena
failures: Array<Error>
#
2 inherited members
From Error
Properties
zena
message: String
#
Methods
zena
getStackTrace(): String | null
#

Interfaces

Waiter

zena
interface Waiter
Re-exported from zena:async/executor.zena

One unit of queued work.

The same type serves every role: a suspended async frame (the split pass synthesizes an implementation per async function), a callback bridge, a combinator's subscription, and a bare closure. Because a waiter reads the settled value out of the future rather than being handed it, this needs no type parameter, so one implementation per frame serves every await site in a function whatever it awaits.

Methods
zena
run(): void
#

AsyncIterator

zena
interface AsyncIterator<T>
Re-exported from zena:async/step.zena

An iterator whose next() may report a value that is still coming. A for await consumes one; a plain for does not, since it never waits — wrap the iterator in requireSyncIterator to consume it synchronously anyway, and throw if a value is not there yet.

Methods
zena
next(): AsyncStep<T>
#

Advances the iterator and returns the next element as an AsyncStep<T>: Ready with a value, Done when exhausted, or Pending when the value is still coming.

AsyncIterable

zena
interface AsyncIterable<T>
Re-exported from zena:async/step.zena

A source of asynchronous iterators. Distinct from Iterable<T>, and neither is a subtype of the other: a for await accepts both, and a plain for accepts only Iterable<T>.

Type aliases

Awaited

zena
type Awaited<T>
Re-exported from zena:async/future.zena

The type await x has when x: T (docs/design/type-operators.md): Future<U> becomes U, a union's future arms unwrap, anything else passes through — one level, matching what one await does.

AsyncStep

zena
type AsyncStep<V> = inline (0, _, _) | inline (1, inline? V, _) | inline (2, _, Future<Option<V>>)
Re-exported from zena:async/step.zena

AsyncStep — one result of an asynchronous iterator's next(). It is an inline multi-value, so it travels in stack slots and the synchronous cases allocate nothing.

The three arms are the dispositions of a single step:

  • Done (discriminant 0): no more items.
  • Ready (discriminant 1): a value, available now.
  • Pending (discriminant 2): a value or the end is coming; await the future to learn which. The future carries Option<V> — an inline step cannot be a future's type argument, and Option<V> is the storable form that still says whether the awaited item is a value (Some) or the end (None).

A caller always learns the disposition synchronously, from the discriminant; only in Pending does it wait, and then only for the eventual item. The synchronous Step<V> (zena:core) is the first two arms, so every synchronous step is also an asynchronous one.

Functions

currentScope

zena
function currentScope(): CancelScope

The scope new async work would bind to; sync code polls this to stop early without any signature change.

checkCancellation

zena
function checkCancellation(): void

The opt-in checkpoint for CPU-bound work with no natural suspension point — a parser's token loop, a long numeric pass. Raises on the cancellation channel if the current scope has been cancelled, exactly as a real suspension point would, so cleanup (finally, using, cancel clauses) and propagation work unchanged. currentScope().isCancelled remains the poll-only form, for code that stops early without unwinding.

An observe site, not a cause: calling this never opens the whole-program cancellation gate, and under a closed gate the check is a branch never taken.

scheduleTask

zena
function scheduleTask(task: Waiter): void
Re-exported from zena:async/executor.zena

Enqueue a task on the microtask queue.

scheduleMicrotask

zena
function scheduleMicrotask(cb: () => void): void
Re-exported from zena:async/executor.zena

Enqueue a callback on the microtask queue.

drainMicrotasks

zena
function drainMicrotasks(): void
Re-exported from zena:async/executor.zena

Run microtasks until the queue is empty.

runFuture

zena
function runFuture<T>(f: Future<T>): T
Re-exported from zena:async/future.zena

Drains the microtask queue and returns f's value, throwing its failure.

The public way to get a value out of a future from synchronous code, and deliberately the only one: it cannot answer before the queue has run, so there is no way to observe whether something settled "early". If f is still pending once the microtask queue is empty, that is a deadlock and it throws saying so.

Top-level only — a driver, a test, main. Calling it from inside a task would re-enter the drain and break run-to-completion.

T may be an owner (runFuture(make())): the value comes out of f by a take, so the caller owns it, and f, released at exit as an owned parameter is, holds nothing to release.

requireSync

zena
function requireSync<T>(source: AsyncIterable<T>): Iterable<T>
Re-exported from zena:async/step.zena

source as a synchronous iterable: each iterator it produces is requireSyncIterator over the source's, so a plain for can drive it, and a Pending step throws AsyncInSyncIteration.

requireSyncIterator

zena
function requireSyncIterator<T>(source: AsyncIterator<T>): Iterator<T>
Re-exported from zena:async/step.zena

source as a synchronous iterator: every step it forwards is Ready or Done, and a Pending step throws AsyncInSyncIteration — the value is not there yet, and a synchronous consumer cannot wait for it. The future is never read, even one that has already settled.

Symbols

release

zena
symbol release
Re-exported from zena:async/future.zena

What an implicit drop of a future of an owner calls ([release]).