Loops

Status: implemented — nested while and classic for bodies with conditionals and explicit exits

export default n => {
    let value = 1;
    let steps = 0;
    while (value < n) {
        value = value * 2;
        steps = steps + 1;
    }
    return [value, steps];
};

while (condition) { … } tests its condition before each round, including the first. A false condition skips the body; after normal completion, following statements read the values the last round left. Conditions use the same truth test as ?:. No ; follows the closing }.

A loop may stand at module level, in a function body, in a terminal guard's or case's block, inside another loop or conditional arm, or after another loop. Module loops run in initialization order, including between named exports; earlier exported constants retain their declared values (modules). Its body holds consts and initialized lets, call statements, assignments and conditional blocks. It may assign zero, one or several outer variables, including parameters. Assignments take effect in source order: a later assignment reads the value an earlier one established. Loop-local declarations belong to that round, and a shadowed name denotes its own binding.

Classic for

for (init; condition; step) { … } uses the same loop machinery as while. The initializer runs once before the first test. Each truthy test runs the body, then the step, then the next test. A falsy test skips both the body and step. The three header clauses are optional; an omitted condition is true. Both semicolons and the body braces are required.

let word = 0;
for (let i = 0; i < count; i += 1) {
    word |= input[Number(first + i)] << 8 * i;
}

The initializer may be one initialized let or const declaration, an assignment, a call statement, or empty. The step may be an assignment, a call statement, or empty. Existing pattern, assignment and call restrictions apply to those clauses. ++, --, for…of and for…in remain separate features. The condition is an ordinary expression. Computed property keys still require their explicit conversion, as Number(first + i) does above.

A header declaration belongs to the loop: it is visible in the condition, step and body, and ends after the loop. The body owns a nested lexical scope; a declaration there may shadow the header binding without changing which binding the step updates. Sequential loops may reuse the same header name. The usual write-based capture rule applies to header bindings, so a held function cannot capture an index reassigned by the step. A body-local const snapshot can preserve that round's value.

An owning continue runs the step before testing again, including a continue inside a conditional or switch. An owning break, return or throw skips the step. A nested loop keeps its own transfers. Normal body completion and continue each run the step exactly once.

The direct expansion is a lexical region containing the initializer followed by while (condition) { body; step; }, with the same step before each owning continue. No new graph or VM operation is needed.

Loop bodies and exits

Initializers, call statements and assignment computations run when reached, even if nothing reads their values. For example, r = null.x; r = 1; throws at the first assignment. The condition runs once at every reached test, using the preceding round's values; a failed test or body computation does not complete the loop normally.

An if (condition) { ... } inside a loop executes its block when the condition is truthy. An optional else block runs when it is falsy; else if tests the next condition only when preceding conditions were falsy. A normally completing selected block continues with the following statement. Assignments update the enclosing loop's variables; an arm that leaves a variable unassigned preserves its preceding value. Each block owns its declarations, including nested conditional blocks.

while (i < n) {
    if (i < 2) {
        sum = sum + i;
    } else if (i < 4) {
        sum = sum + 2 * i;
    } else {
        sum = sum + 3 * i;
    }
    i = i + 1;
}

An inner loop completes before the next statement in its enclosing block. Its assignments update enclosing variables in source order. Declarations in the outer body initialize again on every reached outer iteration; declarations inside the inner body belong to each reached inner iteration. An initially false inner condition skips only that body and leaves following statements reachable. Inner loops may also stand in either conditional arm.

while (i < n) {
    let j = 0;
    while (j < 2) {
        sum = sum + i + j;
        j = j + 1;
    }
    i = i + 1;
}

An unlabeled break; finishes the innermost loop with the values established before it. Following statements run once after that loop. An unlabeled continue; starts the next test of the innermost loop's condition with those current values. Both skip the rest of their selected body or conditional arm, including any enclosing conditional's remaining statements. An inner loop's exit leaves its enclosing loop active.

while (i < n) {
    i = i + 1;
    if (i === 3) { continue; }
    if (i > 5) { break; }
    sum = sum + i;
}

Reached initializers and assignments still run before a transfer, even when their results are unused. Skipped statements perform no computations. Their names, declarations and writes remain subject to the ordinary source checks, including the write-based capture rule. A nested function owns its control flow; its body cannot break or continue a loop in the enclosing function. The usual semicolon insertion rules apply to the unlabeled statements.

return value; inside a loop finishes its owning source function, including from either conditional arm or an inner loop. Generated loop helpers do not intercept that return. An ordinary nested function keeps its own returns; calling it does not return from the caller. As elsewhere, a module body cannot return, including from a module loop.

throw value; evaluates its value and propagates the failure immediately. It may occur in a function or module loop. Earlier reached work runs before either exit, following statements perform no computations, and source legality checks still inspect those statements. Return and throw statements follow the same spelling and line-break rules as outside loops.

export default n => {
    let i = 0;
    while (i < n || n < 0) {
        if (n < 0) { throw "negative limit"; }
        i = i + 1;
        if (i === 4) { return i * 10; }
    }
    return i;
};

Switch statements inside loop bodies and conditional arms evaluate their discriminant once and test case labels lazily in source order. Cases use braces, may share stacked labels, and must exit explicitly: no case falls through to another. A default is optional and last.

A break exits the nearest switch or loop. Breaking a switch resumes the statements after it with the case's current state; continue instead retests the nearest enclosing loop. A nested loop owns both of its transfers. Returns and throws retain their existing function and failure behavior. Case-local declarations stay within their braced block, and source legality checks still inspect the text after an exit.

export default n => {
    let i = 0;
    let sum = 0;
    while (i < n) {
        i = i + 1;
        switch (i % 4) {
            case 0: { continue; }
            case 1: case 2: { sum = sum + i; break; }
            default: { sum = sum + 2 * i; break; }
        }
        sum = sum + 10;
    }
    return [i, sum];
};

An unmatched switch without default resumes with unchanged state. A case whose path reaches its closing brace without an exit is refused. break and continue cannot cross an ordinary nested function boundary. Direct module switches and switch-local breaks outside loop bodies remain separate follow-ups; a switch inside a module loop follows these same loop rules.

A zero-state loop is admitted: while (false) {} finishes immediately; while (true) {} does not finish. A source function called inside a body keeps its own returns and may contain its own loops.

A terminal unconditional loop

A final while (true), for (;;) or for (init; true; step) can end a function body, including an existing terminating guard or case block, without a following return or throw when no source break targets that loop:

export default n => {
    let count = n;
    while (true) {
        if (count === 0) { return count; }
        count = count - 1;
    }
};

The condition must be the boolean literal true, with parentheses allowed, or omitted in a for header. No constant folding or truthiness inference is used: 1, !false, and a reference to a constant whose value is true do not qualify. An ordinary loop with a following return keeps its existing rules.

Only a break to this loop disqualifies it. Breaks in an inner loop or switch target that inner construct and remain allowed. A continue cannot complete the loop normally, and returns retain their owning function. Nested functions own their own control flow. All source breaks are considered, including an unreachable if (false) { break; } and a break after a return or throw.

Such a loop has no normal completion: an execution may return explicitly, throw, or continue indefinitely. It cannot silently fall through and return undefined. All declaration, reference and capture checks still apply to unreachable source text. Module loops do not replace the module's required exports or terminal throw.

Source resolution retains the loop's target identity and verifies the absence of an owning break before desugaring. Normalization uses the existing loop operations with no normal result or false-condition exit. The loop itself ends its FAST block; there is no synthetic throw or implicit returned value after it. Graph construction uses the terminal computation directly. A helper whose only completing outcome is an owning function return supplies that result without a normal-completion fallback; no new VM operation is needed.

Bindings and captures

The ordinary variable rules apply. Assignments to constants remain refused. A held function cannot capture a binding reassigned anywhere in its scope, including after the capture or in an unreachable loop. Use a snapshot to preserve the value of a particular round: const snapshot = value; const read = () => snapshot;. A function called where it is written may read the current variable, as in value = (() => value + 1)();. A direct call normalized to an expression block may also assign enclosing variables; a call that retains its ordinary invocation boundary cannot. A never-reassigned let or parameter is capturable.

Normalization and output

Loops add no graph operation. Shared source binding facts select the outer mutable bindings read or written by the loop, in declaration-introduction order. These become explicit FAST input and normal-result signatures, with no fixed-parameter limit on loop state. Immutable outer values remain references; local declarations stay in the body. Each iteration transfers the current values simultaneously after its source assignments have run in order. Explicit continue supplies those current loop inputs; explicit break supplies the current normal results. Source admission resolves the target, and sugar packs the state through the existing FAST transfers.

Loop parameters initialize from the current values at the loop's execution point. Normal completion supplies their latest values once to the following statements. Source binding analysis selects the state; graph conversion consumes the explicit signatures. The structured loop construction contract owns helper construction, completion propagation and graph identities.

For a literal true condition and an explicit exit from that loop, sugar uses the body directly instead of adding an unreachable false-condition arm. This includes an owning-function return or statement throw in a nested loop. An inner loop's own break or continue, or a nested function's exits, do not exit an enclosing loop. Loops without their own explicit exits keep their established graph shape. Conditional blocks supply explicit FAST results for the union of enclosing bindings assigned by normally completing arms, in declaration-introduction order. Both arms start with the same incoming values. A normally completing arm supplies its latest versions; an unchanged binding retains its preceding value. An abrupt arm retains its loop transfer, owning return or thrown value instead. Later statements read the selected normal results. An omitted else supplies the incoming values.

The established scalar shape keeps its recursive graph and exact graph round trip. General state uses the shared aggregate helper. Source serialization round trips through the same graph conversion and stabilizes under its declared normalization, rather than preserving the written variable names or count.

Source .js/.mjs, code-only JavaScript/function text and Rust emit eligible aggregate helpers with plain or conditional bodies as loops and run with bounded stack. Required condition computations run at every reached test. Normal loop completion supplies its results once; a return or throw bypasses the normal continuation. Result closures capture immutable final values. The source spelling can introduce a scalar helper boundary on its first round trip; its normalized graph and canonical text then stabilize. Existing scalar source output retains its graph and bounded-stack spelling, including loops that read an unchanged outer mutable value. The output contract records the eligible helper shape and recursive fallback for other graphs.

Implementation evidence

The parser's loops.generalStateAdmission proof in parser/proof.f.mjs covers zero and multiple state bindings, sequential and selected loops, snapshots and 18 state bindings. Shared sugar proofs in sugar/proof.f.mjs cover binding order, fresh locals, nested return ownership, explicit transfer targets, required failures and state beyond the function-arity limit.

The source-output proofs in source/loops/proof.f.mjs cover conditional and nested bodies, switch exits, initially skipped and failing conditions, continue re-tests, multiple exits and following code. They compare execution and the graph/text fixed point from the first source parse, including the 18-state transfer and owning-return cases.

The classic-for proofs cover header scope and shadowing, optional clauses, update timing, nested transfers, switch continues, owning function exits, required failures and unreachable source checks through parsing, normalization and EDAG execution.

Design approval

The general loop and binding extension was approved by sergey-shandar in PR #3167. The variables approval record also preserves the approval of the earlier single-variable loop subset.

Classic for desugaring was explicitly requested by sergey-shandar in the implementation session. The authorization and scope record quotes that request and lists the implemented header variants. No separate formal approval comment is recorded beyond that request. The general-loop approval above covers while and bindings, not classic for.

Examples

The compiler demo offers the programs loops in module.f.js, each with its real output.

The demo on this page runs sample.f.js, this page's example: the least power of two not below the input, together with the number of steps. It shows the expression graph (EDAG), Rust and WebAssembly output. Run calls the default export with 5 and 100, as JavaScript and WebAssembly, and returns [8, 3] and [128, 7].

Reference

Demo

In FunctionalScript

fjs compile accepts this module, and it runs as JavaScript and as WebAssembly alike.

A FunctionalScript module and what fjs compile makes of it: the expression graph it lowers to, the Rust it compiles to on the NaNVM runtime, and the WebAssembly that Rust builds to. Run calls its default export with 5, then 100, first as the JavaScript it already is and then as WebAssembly, and shows what each call returned or threw and how long one call takes, timed over 1000 calls.

FunctionalScript source:

/**
 * The example of [`README.md`](./README.md) (Loops) as a function of one
 * number, for the page's WASM demo: the least power of two not below the
 * input, and the number of doublings needed to reach it.
 *
 * @module
 */

/** @type {(n: number) => readonly [number, number]} */
export default n => {
    let value = 1;
    let steps = 0;
    while (value < n) {
        value = value * 2;
        steps = steps + 1;
    }
    return [value, steps];
};

Expression graph (EDAG) that fjs compile lowers it to:

=>lengthbody1[][]01.objidx()0.objidx()1()callee...=>lengthframe 0body1arg 0,[]0,anchorresult[]0110?:condthenelse<leftright.frame 0()callee...[]01[]0[]01*leftright.2+leftright.1.objidxarg 01.objidxarg 00

The same graph, as fjs compile writes it to a .edag.data.js file:

const $0=["arg",0];const $1=[".",$0,1];const $2=[".",$0,0];const $3=["()",["=>",1,[["arg",0]],[",",[$1,["?:",["<",$2,["frame",0]],["()",["self"],[["[]",[["*",$2,2],["+",$1,1]]]]],["[]",[$2,$1]]]]]],[["[]",[1,0]]]];export default ["{}",[[":","default",["=>",1,[],["[]",[[".",$3,0],[".",$3,1]]]]]]];

Rust that fjs compile writes from it, on the NaNVM runtime:

// @generated by `fjs compile`. Do not edit: recompile the source module instead.

use nanvm_lib::vm::unstable::{f64_any, string_key};
use nanvm_lib::vm::{Any, Array, IStaticFunction, ToAny, ToArray, ToObject};

#[rustfmt::skip]
#[allow(clippy::if_same_then_else)]
pub fn module<A: IStaticFunction>(_runtime: Any<A>) -> Result<Any<A>, Any<A>> {
    Ok([(string_key("default"), A::static_function(|_self, _args| {
        let _b14: Any<A> = A::static_function(|_self, _args| {
            let mut _b1103: Any<A> = _args.argument(0);
            '_loop14: while Any::<A>::to_boolean({
                let _b1104: Any<A> = _b1103.clone();
                let _b2: Any<A> = (Any::dot(_b1104.clone(), f64_any(0x3ff0000000000000)).end())?;
                let _b3: Any<A> = (Any::dot(_b1104.clone(), f64_any(0x0000000000000000)).end())?;
                (Any::lt(_b3.clone(), A::frame(_self)[0].clone()))?
            }) {
                let _b778_9: Any<A> = _b1103.clone();
                let _b2: Any<A> = (Any::dot(_b778_9.clone(), f64_any(0x3ff0000000000000)).end())?;
                let _b3: Any<A> = (Any::dot(_b778_9.clone(), f64_any(0x0000000000000000)).end())?;
                let _b1107: Any<A> = [(_b3.clone() * f64_any(0x4000000000000000))?, (_b2.clone() + f64_any(0x3ff0000000000000))?].to_array().to_any();
                _b1103 = _b1107.clone();
            }
            let _b1105: Any<A> = _b1103;
            let _b797: Any<A> = {
                let _b1106: Any<A> = _b1105.clone();
                let _b2: Any<A> = (Any::dot(_b1106.clone(), f64_any(0x3ff0000000000000)).end())?;
                let _b3: Any<A> = (Any::dot(_b1106.clone(), f64_any(0x0000000000000000)).end())?;
                [_b3.clone(), _b2.clone()].to_array().to_any()
            };
            Ok(_b797.clone())
        }, 1, [_args.argument(0)].to_array(), Some("($1)=>{let $2=$1;while((()=>{const $3=$2;const $4=$3[1];const $5=$3[0];return $5<$0;})()){const $6=$2;const $4=$6[1];const $5=$6[0];const $7=[$5*2,$4+1];$2=$7;}const $8=(()=>{const $9=$2;const $4=$9[1];const $5=$9[0];return [$5,$4];})();return $8;}")).to_any();
        let _b16: Any<A> = (Any::call(_b14.clone(), [[f64_any(0x3ff0000000000000), f64_any(0x0000000000000000)].to_array().to_any()].to_array().to_any()))?;
        Ok([(Any::dot(_b16.clone(), f64_any(0x0000000000000000)).end())?, (Any::dot(_b16.clone(), f64_any(0x3ff0000000000000)).end())?].to_array().to_any())
    }, 1, Array::default(), Some("($0)=>{const $1=($2)=>{let $3=$2;while((()=>{const $4=$3;const $5=$4[1];const $6=$4[0];return $6<$0;})()){const $7=$3;const $5=$7[1];const $6=$7[0];const $8=[$6*2,$5+1];$3=$8;}const $9=(()=>{const $10=$3;const $5=$10[1];const $6=$10[0];return [$6,$5];})();return $9;};const $11=$1([1,0]);return [$11[0],$11[1]];}")).to_any())].to_object().to_any())
}

WebAssembly text (WAT) of the functions compiled from this source, without the NaNVM runtime:

Refused:

File size 219658 exceeds maximum allowed size of 131072 bytes: 'nanvm/demos/wasm/loops.wat.marked'

Emergent Testing

Idle. Press Run to start the suite.

  • ./proof.f.mjs