Skip to content

Async Programming

Dovetail models asynchronous work as values, not as a hidden runtime feature bolted onto regular functions. An asynchronous computation is a description of work to be performed — it doesn’t run until something explicitly drives it.

This chapter covers the Async type, how to write async functions, the await operator, how to run an async program from main, and how to compose work in parallel.


Async<T, E> describes a computation that, when run, eventually produces either a value of type T or fails with an error of type E.

let greeting: Async<String, Never> = Async.succeed("hello")
let broken: Async<Int32, String> = Async.fail("not found")

The two type parameters are independent. T is the success type; E is the typed error channel. A computation that cannot fail uses Never for E — this is common enough to have a dedicated alias:

type Task<T> = Async<T, Never>

Async values are inert. Constructing one does no work — it merely builds a description. Work only happens when something runs the value (see 12.4).

The two primitive constructors are Async.succeed and Async.fail:

let ok: Async<Int32, Never> = Async.succeed(42)
let bad: Async<Int32, String> = Async.fail("oops")

Most async values, however, come from async functions (12.2) or from library calls such as Async.sleep, file I/O, or network operations.

A failure in an async program is more than just a typed error. The runtime distinguishes three kinds of failure, captured by Cause<E>:

  • Failed(e) — a typed error of type E (the same channel as Async.fail)
  • Panicked(message) — an async defect, such as Async.failPanic(message); a language-level panic or failed assert instead aborts execution with a Wasm trap
  • Interrupted — the fiber was cancelled (see 12.5)

Most code only cares about typed failures; the other variants surface through panic handlers and structured-concurrency cancellation.


An async function is declared with the async keyword before function. Inside its body, await is allowed.

async function fetchGreeting(): Async<String, Never> =
await Async.succeed("Hello, World!")

The body of an async function returns the success type (T), not an Async<T, E> value. To take a value out of an existing async value, use await; to propagate a typed failure, await Async.fail(...).

The return type is required. The compiler does not infer the return type of an async function — you must write : Async<T, E> explicitly.

async function add(a: Int32, b: Int32): Async<Int32, Never> =
await Async.succeed(a + b)
async function lookup(key: String): Async<String, NotFound> =
if key == "answer" then
await Async.succeed("42")
else
await Async.fail(NotFound(key))

async may also be applied to lambdas. The return type is still required:

let f: (Int32) => Async<Int32, Never> =
async (x: Int32): Async<Int32, Never> => await Async.succeed(x * 2)

Use async do to construct a deferred computation directly inside an expression:

let program = async do
let first = await Async.succeed(20)
let second = await Async.succeed(22)
first + second

The entire body is deferred, including ordinary statements before the first await. Its final expression supplies the success value. Running the value again executes the body again, with fresh local variables. Captures follow closure rules: immutable bindings are copied and mutable bindings are shared. Constructing the value does not start a fiber.

The type is inferred from the awaited values using their Awaitable.Rebind implementations. Async is one such type; Resource and custom Awaitable types work the same way. Awaits inside nested async expressions or closures do not determine the enclosing expression’s type. Incompatible computation contexts require an explicit annotation or a conversion.

Without an await, provide the Awaitable type through context:

let program: Async<Int32, Never> = async do 42
function makeProgram(): Async<Int32, Never> = async do 42

A function parameter can also supply that context. There is no default Awaitable type for async do 42 without context.

An async expression has its own early-return and await context. orReturn exits that computation, and loops inside it cannot break or continue an outer loop. A final expression that itself produces an Awaitable remains a nested value; use await to obtain its result.

Currently, while conditions cannot contain await or use, and an async loop whose body contains either cannot use break or continue to exit that iteration. Synchronous loops inside an async expression support ordinary loop control.

Custom implementations must provide Awaitable.defer(body: ByName<Self>, trace: SourceLocation): Self. This operation stores the by-name value without evaluating it, then evaluates it and drives its result on each execution.


await is a prefix operator — it goes in front of the expression being awaited, not after it. It is only legal inside an async function, async closure, or async do expression.

async function greetTwice(): Async<Unit, Never> =
let first: String = await fetchGreeting()
let second: String = await fetchGreeting()
debug(first)
debug(second)

await composes with if, match, and while:

async function pickGreeting(formal: Bool): Async<String, Never> =
if formal then
await fetchGreeting()
else
await Async.succeed("yo")
async function countUp(n: Int32, sink: (Int32) => Unit): Async<Unit, Never> =
let mutable i: Int32 = 0
while i < n do
let v: Int32 = await Async.succeed(i + 1)
sink(v)
i = i + 1
  • await outside an async function, async closure, or async do expression is a compile error.

main is a regular synchronous function returning Unit. To launch async work, call Async.run with the program:

function main(): Unit =
Async.run(app())
async function app(): Async<Unit, Never> =
let message: String = await fetchGreeting()
debug(message)

Async.run:

  • Spins up the runtime and drives the event loop until the program finishes.
  • Returns normally when the program succeeds with Unit.
  • On a typed failure or panic, prints the error to stderr and calls Process.exit with an error code.

Mutex.make, Async.makePromise, and Queue.make return descriptions that allocate fresh state when executed. Running the same constructor program twice creates two objects. Internally, allocation uses Async.thunk, even though constructing a class or a waiter itself needs no scheduler instruction.

async function freshPromises(): Async<Unit, Never> =
let make: Async<Promise<Int32, Never>, Never> = Async.makePromise()
let first = await make
let second = await make
assert !ClassIdentity.equals(first, second)

By contrast, constructing one object and putting it in Async.succeed captures that existing object for every execution. Preserve this distinction when building reusable programs. Process-wide mutexes use a separate internal constructor for the library’s global streams.

The standard Async API in standard-io provides the common building blocks:

async function delayed(): Async<Unit, Never> =
await Async.sleep(Duration.ofMillis(500i64))
debug("half a second later")

Two async values run sequentially when you await them in order. To run work concurrently, fork it.

fork schedules an async value as a child fiber and returns immediately with a handle. join waits for that fiber to finish.

async function inParallel(): Async<Int32, Never> =
let a: Fiber<Int32, Never> = await slowAdd(1, 2).fork()
let b: Fiber<Int32, Never> = await slowAdd(3, 4).fork()
let x: Int32 = await a.join()
let y: Int32 = await b.join()
await Async.succeed(x + y)

Both fibers run concurrently. The two join calls wait for whichever fiber needs more time; the second join returns immediately if its fiber already finished.

Every fiber you fork belongs to a scope: the innermost enclosing use block, or the whole body of the fiber that forked it when there is no use. A scope does not finish — a use does not release its resource — until every fiber in it is gone. That is what stops a resource from being closed underneath a fiber still reading from it.

What “gone” means depends on how the scope ends and how you forked:

scope ends normally scope ends by failure or interruption
fork waits for the fiber interrupts it, then waits
forkBackground interrupts it, then waits interrupts it, then waits
async function serveAndWait(): Async<Unit, NetError> =
let listener: TcpListener = use Tcp.bind("127.0.0.1:8080")
// Structured: the `use` block will not release the listener until this
// fiber has finished, so it can never read from a closed listener.
let handler: Fiber<Unit, Never> = await handleOne(listener).fork()
await handler.join()

Use fork for work the scope should finish, and forkBackground for work that is only meaningful while the scope lives — a poller, a pump, a loop that ends only when the resource it reads from is closed. That last case is the one to watch: a fiber that stops only when the resource closes must not be a plain fork, or the scope will wait for the fiber while the fiber waits for the scope. The runtime detects that and panics naming both fibers rather than hanging silently.

Note that even a background fiber is waited for once it has been interrupted — the scope waits for it to finish unwinding, so its own finalizers always run before the resource is released.

There is deliberately no third option that escapes scopes altogether. A fiber that outlives the resource it was reading from is exactly the hazard scopes exist to prevent, and every use for one turned out to be better served by forking it from a scope that lives long enough — usually the caller’s, one level further out.

Fiber.interrupt requests cancellation of a fiber. Interruption surfaces inside the fiber as Cause.Interrupted. Code that needs to be uncancellable for a critical section can wrap it in Async.uninterruptible.


  • Async<T, E> is a value describing future work; building one does no work.
  • async function f(): Async<T, E> = ... declares an async function; the return type is mandatory.
  • await e is a prefix operator that yields the success value of an async value inside an async body.
  • Async.run(program) from a synchronous main drives the runtime to completion.
  • fork plus join runs work concurrently; structured concurrency means a scope waits for the fibers forked inside it, and forkBackground marks the ones it should cancel instead.

A bare intermediate expression returning Async produces a warning: constructing a deferred computation does not execute it. Inside a compatible async context, use await to execute it, or return or compose the computation for later use.

async function pause(): Async<Unit, Never> =
await Async.sleep(Duration.ofSeconds(1i64))

let _ = computation explicitly acknowledges intentional discard. It does not execute the computation. Bindings and arguments count as using a value; this warning does not prove that every stored computation is eventually executed.

ScopeContext<T> supplies values to an executing scope and its descendant fibers. Inject one context identity into consumers, then use context.scope(value) at the execution boundary. Children inherit at creation; nested bindings shadow locally, and joining never merges changes into the parent. See Scope Context for the API, optional lookup, and lifetime rules.

Use let clock = use TestClock.install(initial) and await clock.advance(duration) to drive Async.sleep, timeout, and time reads without real waiting. Advancement processes deadlines and settles fibers using that clock; it does not simulate host I/O or automatically finish sleepers during scope shutdown. See Scoped Test Clocks for a complete example.