Closures & Iteration
Functions that capture their surroundings, for..of loops,
spread arguments, and generators — the extensions upstream AssemblyScript
never shipped.
Closures
Upstream AssemblyScript’s oldest open request (issue #798): functions cannot capture enclosing locals. rasc implements them natively. Every function value is a managed closure object — a function table index plus captured fields — passed as a hidden context argument; plain function references get forwarding thunks, so they mix freely.
export function counter(): i32 {
let count = 10;
let inc = (by: i32): i32 => { count += by; return count; };
inc(5); // 15
return inc(3); // 18 — mutation persists across the closure's calls
}
The semantics to internalize: capture is by value at closure creation. Assigning to a captured variable inside the closure updates the closure’s own context and persists across its calls — but the enclosing scope does not observe it, and later writes to the outer local are not seen by the closure:
export function demo(): i32 {
let n = 1;
let f = (): i32 => n;
n = 5;
return f(); // 1 — the closure snapshotted n at creation
}
Contexts are synthesized managed classes: GC-traced, safe to escape through
globals, arrays, fields, and returns; captured strings and objects survive
collection. Struct locals capture by value like scalars, and so does
this in a readonly struct method. A bare member name inside a
lambda captures this exactly as an explicit this.name
does. Two limits: refs cannot be
captured (a mutating struct method holds this as a ref, so copy the
fields you need to a local first), and lambda parameters need type annotations
unless the context supplies a function type. Note that closures are suite-tested
under the GC runtimes; under the stub runtime prefer class-based listeners.
Function-typed fields and accessors call directly — the closure loads off the receiver and dispatches indirectly, no copy to a local needed:
function echo(id: i32): i32 { return id; }
class Button {
onTap: (id: i32) => i32 = echo;
}
export function demo(): i32 {
let b = new Button();
b.onTap = (id: i32): i32 => id * 2;
return b.onTap(21); // 42 — plain functions and closures mix freely
}
for..of
for (let x of arr) { total += x; } // Array/StaticArray, typed arrays: index loop
for (let k of map.keys()) { use(k); } // arrays from keys()/values()
for (let c of name) { last = c; } // strings: one code point per step, as String
for (let v of new Range(10)) { ... } // protocol path: values()/iterator(), then next()
Array, StaticArray, the typed arrays, and any class
with a length getter and operator[] lower to an index
loop — under -O the reads go unchecked. Strings iterate the way JS
does: one code point per step, surrogate pairs kept whole, each yielded as a
String:
export function wideChars(): i32 {
let wide = 0;
for (let c of "wide \u{1F642} char") {
if (c.length == 2) { wide++; } // the emoji stays whole
}
return wide; // 1
}
Everything else uses the protocol path: the compiler calls
values() or iterator() once if present, then
next() repeatedly, which must return a reference class with a
done flag and a value field, struct values included.
Those calls pass no arguments, so any parameters take their defaults. The
protocol outranks length
plus [] when a class declares both. All of it resolves statically
per monomorphized type — plain direct calls, no dynamic iterator objects, no
Symbol.iterator. The binding must be a single let;
an annotation on it converts each element as an assignment would.
break/continue work.
Spread arguments
function blend(r: f32, g: f32, b: f32): f32 {
return r * 0.3 + g * 0.6 + b * 0.1;
}
export function demo(): f32 {
let c = FixedArray<f32, 3>();
c[0] = 1; c[1] = 0.5; c[2] = 0.25;
return blend(...c) + blend(...[1, 1, 1]);
}
Spread sources are static-length only — array literals and
FixedArray — so the element count is a compile-time fact and every
check stays at compile time: arity, implicit conversions, and defaults behave
exactly as if the elements were written inline. A trailing spread fills the
remaining parameters; literal elements evaluate left to right, and a
FixedArray spread unpacks value copies, struct elements included.
Spreading a dynamic array or a string, spreading into a constructor, a
non-final spread, and supplying a ref parameter are all clean
compile errors.
Generators
function* counter(n: i32): i32 { // return type = yielded element type
for (let i = 0; i < n; i++) {
yield i;
}
}
export function demo(): i32 {
let sum = 0;
for (let x of counter(3)) { // generators satisfy the for..of protocol
sum += x;
}
return sum + counter(9).next().value; // 0+1+2, plus a fresh .next().value
}
Calling a generator allocates a managed state object holding done,
value, the parameters, and the locals that live across a yield;
next() returns the object itself, and the body is flattened into
state segments driven by a dispatch loop. Instances are independent and
GC-traced. Locals keep their block scope, a for..of may yield
from its body, and a generator is an ordinary function value, generic or
not. As in JavaScript, a throw ends the generator, and so does any other trap
in its body. A next() that reaches a generator whose body is still
running reports done instead of replaying it, where JavaScript throws, and the
running call still yields its value. A generator cannot be
unsafe: a raw pointer it
kept across a yield would go stale when a collection moves the object.
yield is statement-only, by construction: no wasm
operand-stack value ever lives across a suspend. let x = yield ...,
(yield 1) + 2, and yield* are syntax errors, and a
switch containing a yield is rejected.
Async functions
import { Promise } from "rive/promise";
class Loader {
ready: Promise<i32> = new Promise<i32>();
sum: i32 = 0;
async total(extra: Promise<i32>): Promise<i32> {
let a = await this.ready; // suspends until ready settles
let r = await extra.settled(); // never rejects: ok, value, error
return r.ok ? a + r.value : a;
}
}
export function demo(): i32 {
let loader = new Loader();
let extra = new Promise<i32>();
extra.reject("offline");
loader.total(extra).andThen((v: i32): void => {
loader.sum = v;
});
loader.ready.resolve(40); // total() resumes right here
return loader.sum; // 40: extra failed, so r.ok is false
}
Functions, methods and arrows marked async return a
Promise<T> from rive/promise, and await p
gives p's value. The body runs synchronously up to the first await of a
pending promise; awaiting a settled one continues without suspending, and a
suspended body resumes inside whatever settles its promise. There is no
microtask queue, as with the Luau Promise.
A rejected await, or a throw, stops the function and rejects its
promise with the same message: a thrown string or std error gives its
message, another object its message field or else its class
name. await p.settled() never rejects and
reports ok, value, error and
cancelled. Cancelling the returned promise stops the function and
cancels the promise it is waiting on. An async arrow passed where the
callback returns void runs without a promise.
Like a generator, the function keeps what lives across an await in a
GC-traced frame, which its own body allocates before running the rest as a
step function; a suspended frame survives collections. In this version
await is a whole statement, a variable's initializer, the right
side of =, or return await; inside a larger expression
or a condition it is an error, as is a switch containing one.
As in JavaScript, an assignment evaluates its target first:
o.f = await p and a[i] = await p store into the
o, a and i from before the await.
Unsafe functions, accessors and constructors cannot be async, and neither
can host hooks or trigger inputs: start the async work from them. An async
closure cannot assign to a variable it captures, since each call works on
its own copy; keep such state in an object.