Skip to content
Noodle
InstallLearnPlayground
GitHub

Asynchronous code

Use asynchronous functions when work may suspend while waiting for a platform operation or another computation. Noodle keeps success, business failure, and cancellation as distinct typed outcomes.

datatype LoadError
LoadFailed(String)
end
async func load_count() throws LoadError -> Int do
42
end

The declared body result is Int. Callers receive Promise[Int, LoadError]. An async function without throws uses the empty standard-library error set NoError.

Promise[U, E] is eager and freely shareable: calling an async function starts its body immediately, every holder observes the same outcome, and the promise itself carries no cancellation capability. Promise always takes both type arguments; there is no one-argument spelling.

A function type may carry the same modifier, written async (Int) -> Promise[Int, LoadError]. It describes a callee that requires async context and returns exactly the promise written in its result position; the modifier adds no second promise layer. async () -> Int and async () -> Async.Scope are invalid, and a synchronous function that returns a promise has an unmarked type and is a different type from the async function type with the same parameters and result.

.await() is available only inside an async context. It waits for the promise’s real outcome and yields it; it never substitutes an early Cancelled for an operation that is still running:

datatype LoadError
LoadFailed(String)
end
async func load_count() throws LoadError -> Int do
42
end
async func doubled_count() throws LoadError -> Int do
outcome = load_count().await();
switch outcome
case AsyncResult::Ok(value) then value * 2
case AsyncResult::Err(error) then throw error
case AsyncResult::Cancelled(_) then 0
end
end

Waiting produces AsyncResult[T, E] with three constructors:

  • Ok(T) for success;
  • Err(E) for the promise’s declared business error;
  • Cancelled(CancelReason) when the producer reported cancellation.

Cancellation is not merged into the error set. Waiting creates no authority over the producer: a borrowed promise is neither cancelled nor reparented by a consumer, so a wait can outlive the consumer’s own cancellation request.

Postfix ! on AsyncResult extracts Ok and propagates both Err and Cancelled through the enclosing async function:

datatype LoadError
LoadFailed(String)
end
async func load_count() throws LoadError -> Int do
42
end
async func doubled_count() throws LoadError -> Int do
value = load_count().await()!;
value * 2
end

Use this form when the current function does not own recovery. Use an explicit switch when each outcome needs local behavior.

An ordinary async call has no cancellation capability: it is owned work, but nothing in the language can request that its scope stop. Async.spawn starts a callback as the owned body of a new child scope and returns an Async.Task, the handle that carries the right to cancel that scope:

async func with_deadline() -> Int do
task = Async.spawn(|| slow_lookup(7));
_ = task.cancel();
switch task.promise.await()
case AsyncResult::Ok(value) then value
case AsyncResult::Err(_) then 0
case AsyncResult::Cancelled(_) then 0
end
end
async func slow_lookup(key : Int) -> Int do
key
end

Async.Task[T, E] is a datatype with a private constructor and read-only promise : Promise[T, E] and scope : Async.Scope fields. It is deliberately not awaitable: observe the result through task.promise, and cancel through task.cancel(). The private constructor is the capability boundary — user code cannot combine an arbitrary promise with a scope to build a handle — and reading a handle’s scope grants spawn authority without granting cancel power over anything else.

cancel() is synchronous and idempotent, requests cancellation of the task’s scope and its descendants, never its ancestors or siblings, and returns without waiting. It is a no-op once the task’s promise has fulfilled. Cancellation is a request, not a forced outcome: the producer’s own Ok, Err, or Cancelled result is what the promise fulfils with.

Async.Scope is an opaque, freely shareable value that grants the right to start work under one scope. A body obtains its own scope with Async.current_scope(), and a call that takes a scope answer can receive one as an ordinary parameter:

async func start_report(scope : Async.Scope) -> Async.Task[Unit, NoError] do
Async.spawn(|| write_report(), ?{scope: scope})
end
async func write_report() -> Unit do () end

Async.spawn(f) parents the new scope onto the caller’s current scope, so it requires async context. Async.spawn(f, ?{scope: s}) parents it onto s and is legal from any body, including a synchronous one. A target that is cancelled, still closing, or exited never runs the callback: the returned handle’s promise is already Cancelled carrying that scope’s first reason.

An ordinary async call runs under the scope of its call site, and its return does not end that scope or cancel work started under it. Only Async.spawn, async bindings, and the runner’s roots create scopes.

When an owning body exits — normally or with Err or Cancelled — its scope stops admitting new work, requests cancellation of the work it owns, and joins that work before publishing the owner’s own result. The join covers unawaited spawned tasks, ordinary async calls made under that scope, and owned host operations, and it waits for real termination rather than for a cancellation notification. Cancelling leftover work during the exit never rewrites the owner’s Ok or Err. Work that does not cooperate can therefore delay scope completion indefinitely.

Because the join is unconditional, there is no shielding or privileged cleanup scope. To start cleanup work after cancellation, spawn it onto an explicitly supplied scope that is still active; the cleanup belongs to that target scope and the original scope waits for it only if the program awaits its promise.

Async.checkpoint() reports the calling scope’s cancellation state synchronously as an ordinary value:

async func step() -> Unit do
switch Async.checkpoint()
case AsyncResult::Ok(_) then ()
case AsyncResult::Err(_) then ()
case AsyncResult::Cancelled(_) then ()
end;
end

When the scope has a cancellation request, checkpoint() returns AsyncResult::Cancelled(reason) with the scope’s recorded first reason; otherwise it returns AsyncResult::Ok(()). It is a synchronous, context-gated operation, not an async function: it does not suspend, does not yield to the event loop, and never forces a return. Async.checkpoint()! propagates cancellation under the ordinary postfix ! rules, and handling a cancelled result neither clears the request nor reopens the scope for new work.

A scope that no longer admits ordinary work starts no new ordinary async call: the call returns an already-fulfilled Cancelled with the scope’s first reason without running the invoked body, not even its synchronous prefix. The callee and every explicit argument are still evaluated exactly once and in source order. Already-running work remains cooperatively cancellable rather than preempted, and awaiting an existing promise, matching an AsyncResult, and holding an existing promise are not new production, so they stay legal.

An entry file may declare async func main() -> Unit. The runner gives it a fresh root scope and waits for that root to cancel and join everything the body owned before it publishes the program outcome:

datatype LoadError
LoadFailed(String)
end
async func load_count() throws LoadError -> Int do
42
end
async func doubled_count() throws LoadError -> Int do
value = load_count().await()!;
value * 2
end
async func main() -> Unit do
result = doubled_count().await();
switch result
case AsyncResult::Ok(value) then Debug.trace(value)
case AsyncResult::Err(LoadError::LoadFailed(message)) then
Console.println(message)
case AsyncResult::Cancelled(_) then Console.println("cancelled")
end;
end

At the outer entry boundary, success exits normally, an unhandled business error exits unsuccessfully, and cancellation uses the cancellation exit path. An async test body receives the same fresh-root treatment. Only runners create roots: a host callback that starts async work receives or captures a scope and spawns onto it rather than getting an implicit root.

An async binding starts its initializer immediately as the owned body of a fresh child scope. Its type is AsyncResult[T, E]; a reference to the binding is legal only inside an async context and waits for that background computation, and postfix ! extracts its Ok value:

async func load_name() -> String do
"Ada"
end
async func greeting() -> String do
async name = load_name().await()!;
name!
end

The initializer is checked as an anonymous async body, so it may use .await() and postfix !. A plain value is lifted to AsyncResult::Ok, and errors propagated inside the initializer become that computation’s error outcome. An initializer that already produces an AsyncResult — the bare promise.await(), or any value of that type — is that computation’s own outcome, so the binding takes the awaited call’s AsyncResult[T, E] and promise.await()! is its explicit-propagation spelling.

Multiple bindings start before the rest of the block runs, so they can overlap:

async func load_user() -> String do "Ada" end
async func load_plan() -> String do "Pro" end
async func describe() -> String do
async user = load_user().await()!;
async plan = load_plan().await()!;
user_name = user!;
plan_name = plan!;
user_name ++ " / " ++ plan_name
end

Every exit from the binding’s lexical block — the normal end, an explicit return, ! propagation, a labelled exit, or a loop break or continue that leaves the block — requests cancellation of that scope and joins it before control leaves. The barrier is unconditional: it does not depend on whether the binding was ever referenced, and there is no awaited or consumed flag.

Use an async binding when the lexical scope clearly owns the concurrent work. Call an async function directly when explicit task ownership and waiting make the control flow easier to see.

A promise-returning extern func declaration forms the platform boundary between the language and a host operation. Its companion settles the promise only after the operation really finished its termination and cleanup work: a cancellation request delivered to a host hook is a request, not an acknowledgement. An owned host operation registers with the scope that owns it, which is what lets an owner’s exit cancel and join it, and it reports Cancelled instead of starting when its scope admits no new work. A host rejection, a fulfilment that is not a valid AsyncResult, and a throwing cancellation hook are runtime failures, never a fabricated business Err or Cancelled.

Next: Tests, including asynchronous tests.