concepts

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.

closures.as
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:

capture.as
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:

listeners.as
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

iteration.as
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:

codepoints.as
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

spread.as
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

generators.as
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

async.as
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.