Task[T] is the typed handle for a spawned computation that will produce T.
You get one from spawn, and you consume it with .await().
fn calculate_total(items: []int): int {
mut sum := 0
for v in items {
sum = sum + v
}
return sum
}
fn run(): int {
task: Task[int] = spawn { calculate_total([1, 2, 3, 4]) }
return task.await()
}The handle carries the result type but not the result itself. The child owns its own execution frame until it completes and the parent copies the value out.
The whole surface
Task[T] has three methods. That is the entire API.
| Method | Result | Effect it contributes | Purpose |
|---|---|---|---|
await() |
T |
Suspend |
Join, retrieve the result, reap the frame |
cancel() |
void |
Cancel |
Request cooperative cancellation |
raw_handle() |
usize |
none | Scheduler token for runtime integration |
fn work(): int { return 7 }
fn inspect(): usize {
task := spawn { work() }
handle := task.raw_handle()
task.cancel()
return handle
}There is no is_complete, no timeout, no detach, and no multi-await today. Build
a timeout by racing the task against a timer source once one exists in the target
runtime.
Awaiting
await is a method, not a keyword, and it does three things at once:
- it may suspend the caller while the child is still running;
- it returns the child’s
T; - it reaps the completed task’s frame and control block.
struct Report {
rows: int
label: string
}
fn build_report(rows: int): Report {
return Report { rows: rows, label: "monthly" }
}
fn summarize(): string {
task := spawn { build_report(120) }
report := task.await()
return "${report.label}: ${report.rows} rows"
}T can be any type. A wide result — a struct, tuple, list, or string — is copied
out of the child’s frame before that frame is reclaimed, but the contract at the
source level is identical to returning an int.
Treat a task as single-consumer. Do not await it twice, do not put the same
handle in two select arms, and do not use it after a selection consumed it. The
compiler does not currently reject a double await, so this is a discipline you
keep, not one it keeps for you.
If the child has already finished, await() takes a synchronous fast path and
returns without yielding. The static row still says Suspend, because the caller
cannot know which case it is in.
Completion order
Joining in source order does not serialize the work, as long as every task was started first.
fn fetch_left(): int { println("left") return 1 }
fn fetch_right(): int { println("right") return 2 }
fn overlapped(): int {
left := spawn { fetch_left() }
right := spawn { fetch_right() }
left_value := left.await()
right_value := right.await()
return left_value + right_value
}Both children are eligible to run before the first join completes. The joins only choose the order in which the parent collects.
Contrast that with joining each task at its spawn site, which is strictly sequential:
fn fetch_left(): int { println("left") return 1 }
fn fetch_right(): int { println("right") return 2 }
fn serialized(): int {
left_value := (spawn { fetch_left() }).await()
right_value := (spawn { fetch_right() }).await()
return left_value + right_value
}Same result, no overlap, plus the cost of two task allocations. If you are going to await immediately, just call the function.
Fallible children
Task[T] preserves whatever the body returns. A body of type
Record ! LoadError gives you Task[Result[Record, LoadError]], and awaiting
hands back the Result for ordinary matching or ? propagation.
error LoadError {
NotFound { id: int }
Corrupt
}
struct Record { id: int, body: string }
fn load(id: int): Record ! LoadError {
if id < 0 { error NotFound { id: id } }
return Record { id: id, body: "payload" }
}
fn load_one(id: int): Record ! LoadError {
task := spawn { load(id) }
return task.await()?
}
fn load_or_default(id: int): Record {
task := spawn { load(id) }
result: Result[Record, LoadError] = task.await()
return result catch {
NotFound { id: _ } => return Record { id: 0, body: "" }
Corrupt => return Record { id: 0, body: "" }
}
}The annotated local in load_or_default is not decoration. catch applied
directly to task.await() does not currently see through the generic result
type:
error LoadError { NotFound, Corrupt }
struct Record { id: int, body: string }
fn load(id: int): Record ! LoadError {
if id < 0 { error NotFound }
return Record { id: id, body: "payload" }
}
fn load_or_default(id: int): Record {
task := spawn { load(id) }
return task.await() catch {
NotFound => return Record { id: 0, body: "" }
Corrupt => return Record { id: 0, body: "" }
}
}That reports ATOLL2002: catch requires a Result[T, E] expression. Bind the
awaited value to a local with an explicit Result[T, E] annotation first, or
use match, ?, or unwrap_or, all of which work directly:
error LoadError { NotFound, Corrupt }
struct Record { id: int, body: string }
fn load(id: int): Record ! LoadError {
if id < 0 { error NotFound }
return Record { id: id, body: "payload" }
}
fn fallback(): Record {
return Record { id: 0, body: "" }
}
fn by_unwrap(id: int): Record {
task := spawn { load(id) }
return task.await().unwrap_or(fallback())
}
fn by_match(id: int): int {
task := spawn { load(id) }
match task.await() {
Ok(record) => return record.id
Err(err) => return -1
}
}Cancellation is not encoded as an automatic Err member of T. Define an
application error for it only when task lifetime genuinely belongs in that API’s
domain contract.
Collections of handles
Task[T] is an ordinary value, so it can live in a list or a struct field. That
is how you fan out over a runtime-determined width.
fn score(candidate: int): int {
return candidate * candidate
}
fn best_of(candidates: []int): int {
mut running: []Task[int] = []
for c in candidates {
running.add(spawn { score(c) })
}
mut best := 0
for task in running {
value := task.await()
if value > best {
best = value
}
}
return best
}Pairing a handle with the identity it belongs to is often worth a small struct,
because Task[T] itself carries no label:
struct Shard {
name: string
work: Task[int]
}
fn count_rows(shard: string): int {
return shard.len()
}
fn tally(names: []string): string {
mut shards: []Shard = []
for name in names {
shards.add(Shard { name: name, work: spawn { count_rows(name) } })
}
mut out := ""
for shard in shards {
out = out + "${shard.name}=${shard.work.await()} "
}
return out
}Raw handles
raw_handle() exposes the scheduler’s token as a usize. It exists for the
compiler’s selectable protocol and for low-level runtime integration.
fn work(): int { return 1 }
fn token(): usize {
task := spawn { work() }
handle := task.raw_handle()
task.await()
return handle
}A raw handle transfers neither the result type, nor the right to consume the
result, nor the reaping obligation. Do not persist it, serialize it, compare it
for business identity, or try to rebuild a Task[T] from it — it is an
arena-relative token whose encoding belongs to the runtime ABI. Keep the typed
handle instead.
Ownership
Dropping the source-level handle does not detach the child. It stays in the runtime’s parent/child tree, so subtree cancellation and shutdown still reach it. What you lose is the ability to retrieve its result or cancel it specifically.
Keep the handle when completion, errors, or targeted shutdown matter. Discard it only for deliberately best-effort work that handles its own failures.
Before a scope that started tasks exits, account for:
- every handle whose result is required;
- siblings still running after an earlier join failed;
- tasks that a
selectalready consumed; - cancellation requests whose cleanup is still in flight;
- fallible results that would otherwise go unobserved.
The runtime’s task tree keeps detachment from becoming memory-unsafe. It cannot choose your application’s result, retry, or shutdown policy.
A composed example
Fan out to a set of replicas, collect what succeeded, and count the failures rather than letting them vanish.
error ReplicaError {
Unreachable { replica: int }
Stale
}
struct Sample {
replica: int
value: int
}
fn probe(replica: int): Sample ! ReplicaError {
if replica % 3 == 0 { error Unreachable { replica: replica } }
println("probing ${replica}")
return Sample { replica: replica, value: replica * 10 }
}
struct Survey {
total: int
ok: int
failed: int
}
fn survey(replicas: int): Survey {
mut probes: []Task[Result[Sample, ReplicaError]] = []
mut i := 0
for i < replicas {
probes.add(spawn { probe(i) })
i = i + 1
}
mut total := 0
mut ok := 0
mut failed := 0
for task in probes {
match task.await() {
Ok(sample) => {
total = total + sample.value
ok = ok + 1
}
Err(err) => failed = failed + 1
}
}
return Survey { total: total, ok: ok, failed: failed }
}Note Task[Result[Sample, ReplicaError]] written out in the annotation — that is
what spawn { probe(i) } produces, and matching on the awaited value is how each
failure gets observed instead of silently discarded.