zena:async
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 ​
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
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").
isCancelled: boolean { get; }
TaskGroup
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.
new()
A group whose scope is a child of the scope current at construction, so cancelling an ancestor cancels the group's members too.
isCancelled: boolean { get; }
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.
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.
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.
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.
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
class FutureClaim<T> 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.
new(interest: Interest)
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().
split(): FutureClaim<T>
A second claim on the same work. Throws if this handle was already released — a released handle has nothing to share.
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
class Future<T>
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.
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.
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).
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.
static failed<A>(error: Error): Future<A>
An already-failed future.
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.
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.
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).
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
class AnyCompleter
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.
fail(_error: Error): void
Rejects the underlying future. Overridden by Completer<T>.
Completer
class Completer<T> extends AnyCompleter
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.
future
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.
complete(value: T): void
fail(error: Error): void
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.
tryFail(error: Error): boolean
Settles with a failure, reporting whether it won.
DeferredFuture
class DeferredFuture<T> extends Future<T> implements Disposable
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").
new(produce: () => Future<T>, drop: () => void | null = null)
8 inherited members
Future
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.
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).
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.
static failed<A>(error: Error): Future<A>
An already-failed future.
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.
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.
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).
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
class AggregateError extends Error
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.
Interfaces
Waiter
interface Waiter
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.
run(): void
AsyncIterator
interface AsyncIterator<T>
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.
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
interface AsyncIterable<T>
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
type Awaited<T>
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
type AsyncStep<V> = inline (0, _, _) | inline (1, inline? V, _) | inline (2, _, Future<Option<V>>)
AsyncStepnext(). 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 carriesOption<V>— an inline step cannot be a future's type argument, andOption<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
function currentScope(): CancelScope
The scope new async work would bind to; sync code polls this to stop early without any signature change.
checkCancellation
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
function scheduleTask(task: Waiter): void
Enqueue a task on the microtask queue.
scheduleMicrotask
function scheduleMicrotask(cb: () => void): void
Enqueue a callback on the microtask queue.
drainMicrotasks
function drainMicrotasks(): void
Run microtasks until the queue is empty.
runFuture
function runFuture<T>(f: Future<T>): T
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
function requireSync<T>(source: AsyncIterable<T>): Iterable<T>
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
function requireSyncIterator<T>(source: AsyncIterator<T>): Iterator<T>
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
symbol release
What an implicit drop of a future of an owner calls ([release]).