Control Flow​

Everything you expect from C++ control flow, minus its sharpest edge: switch never falls through by accident, and a runaway loop cannot outlive its execution budget. if/else, while, C-style for, range-based for, break/continue, and try/catch/throw. All of it unwinds correctly out of loops and nested frames on both backends. As in C++, a break with no loop or switch around it, or a continue with no loop, is a parse error ('break' outside a loop or switch); a function or lambda body leaves nothing it sits in. A { that starts a statement opens a block, nested blocks included; a map literal written there goes in parentheses, except at a unit's top level, where {"key": value} is the unit's value (a data file an include reads).

if / while / for​

jaiscript
auto n = 7;
if (n % 2 == 0) { print("even"); } else { print("odd"); }

auto sum = 0;
auto i = 1;
while (i <= 4) { sum += i; i++; }
print("while sum 1..4 = {}", sum);

auto total = 0;
for (int j = 0; j < 10; ++j) {
    if (j == 3) { continue; }
    if (j == 6) { break; }
    total += j;
}
print("for with continue/break: {}", total);
output
odd
while sum 1..4 = 10
for with continue/break: 12

do ... while​

do { body } while (cond); runs the body, then tests the condition, as in C++: the body always runs once, continue goes to the condition and break leaves the loop. The condition cannot see the body's locals. do is a keyword only when a { follows it; anywhere else it is an ordinary name (int do = 3;). There is no goto; writing one is an error that points to break, continue and return:

jaiscript
int tries = 0;
do {
    tries++;
    if (tries == 2) { continue; }      // continue goes to the condition
} while (tries < 4);
int once = 0;
do { once++; } while (false);          // the body runs before the first test
print(tries, " ", once);
output
4 1

Range-based for​

Iterates arrays (elements), maps (kv.first / kv.second pairs, or for (auto [k, v] : m), see Maps), and coroutines (see Coroutines). A plain auto item is a copy of each element (a shared_ptr element's copy is another handle to the same object, as var x = element; is); auto& mutates in place, and the loop variable may name any type (for (shared_ptr<Node> n : nodes), for (const array<int>& row : grid)). auto& is one of the escape hatches from copy-by-default in Value Semantics:

jaiscript
auto items = ["sword", "shield", "potion"];
for (auto item : items) { print("- {}", item); }

auto scores = {"alice": 3, "bob": 5};
for (auto kv : scores) { print("{} scored {}", kv.first, kv.second); }

for (auto& s : items) { s = s + "!"; }
items[0];
output
- sword
- shield
- potion
alice scored 3
bob scored 5
== result: sword!

A typed loop variable takes each element the way its declaration would. By value, for (int v : a) is int v = element; per element: over var a = [1.5, 2.5] it sees 1 and 2, and an element the conversion table refuses (a string) raises the declaration's error. By reference, for (int& v : a) binds only an element that holds an int (a class: the class or a subclass); the checker rejects a mismatch at load when it knows the element type, and a var container is judged element by element. An auto variable takes the elements' static type: over an array<int> it is an int, over a var array (no static element type) it is a var.

switch: break by default, explicit fallthrough;​

Unlike C++, a case ends where the next one begins; fallthrough; continues into the next case on purpose.

The classic C footgun is inverted: cases never fall through unless you write fallthrough;. Case values can be integers, strings, enum members, or expressions, and a case matches when the language's == holds: switch (1) matches case 1.0, an instance matches memberwise or through its operator==, and an error that operator== raises leaves the switch. fallthrough; outside a switch is an error.

jaiscript
function describe(weapon) {
    auto damage = 0;
    switch (weapon) {
        case "sword": damage = 10;
        case "bow":   damage = 8;
        case "magic":
            damage = 15;
            fallthrough;
        case "enchanted":
            damage += 5;
        default:      damage = 1;
    }
    return damage;
}
print("sword: {}", describe("sword"));
print("magic: {} (fell through into enchanted)", describe("magic"));
print("enchanted: {}", describe("enchanted"));
print("fists: {}", describe("fists"));
output
sword: 10
magic: 20 (fell through into enchanted)
enchanted: 5
fists: 1

Note enchanted alone gives 0 += 5 = 5, and no match lands in default. Switches nest, return works from inside a case, and break/continue in a loop-enclosed switch affect the loop, never leak into the switch.

try / catch / throw​

Any value can be thrown. catch (e) binds the error; catch without a variable is allowed; a bare throw; inside a catch re-throws, and one with no active exception raises the catchable Bare throw with no active exception thrown.; throw is an expression, so it composes (x > 0 ? x : throw "negative"). Uncaught script throws surface to the C++ host as exceptions (with a captured script stack trace).

jaiscript
try {
    throw "something broke";
} catch (e) {
    print("caught: {}", e);
}

try { throw 42; } catch (e) { print("thrown values can be any type: {} (e.value + 1 = {})", e, e.value + 1); }

try {
    try {
        throw "inner";
    } catch (e) {
        print("rethrowing {}", e);
        throw;
    }
} catch (e) {
    print("outer caught: {}", e);
}

try { throw "ignored"; } catch { print("catch without a variable"); }
"done";
output
caught: something broke
thrown values can be any type: 42 (e.value + 1 = 43)
rethrowing inner
outer caught: inner
catch without a variable
== result: done

Throws work mid-argument-evaluation, and C++ exceptions interop in both directions: a bound host function, method, operator or property accessor that throws surfaces as a catchable script error with the exception's text where it was called, and an uncaught script throw reaches the host with its script stack trace attached. Inside a catch, e reads as the error text alone, and when the same error escapes to the host its what() is that same text.

A caught error is a value that knows where it came from. e behaves as its message string everywhere (printing it, to_string(e), e + "...", e == "text", a string variable, the string methods), and it also answers e.message, e.file, e.line, e.column and e.stack, the script call stack at the error as file:line strings, one per frame, innermost first (below: pick's line, then the top level's). A stack deeper than 64 frames lists the innermost 64 and ends with one line ... N more frames for the rest, so catching deep in a recursion costs no more than catching near the top. On the VM a bare self tail call (return f(n - 1);) reuses its frame, so a tail recursion leaves one entry where the interpreter lists every level. For a throw the place is the throw itself and e.value is the thrown value, with its type (a thrown object's fields also read straight through e, and its message is its class's to_string(), as print shows it); for a run-time error the place is the statement that raised it and e.value is null. A copy of e answers the same; a new string built from it (e + "") is just text. The thrown value lives until the handler that caught it exits, by completing, a return, a break or a new throw, and then dies as C++ destroys the exception object; a bare throw; carries it on to the next handler. A copy of e kept past its handler still answers its message, place and stack, and its e.value is null.

jaiscript
int pick(array<int> xs, int i) {
    return xs[i];
}
try {
    pick([1, 2, 3], 7);
} catch (e) {
    print("{} (line {}, value {})", e, e.line, e.value);
    print("stack depth: {}", e.stack.size());
}
class Oops { int code = 7; }
try { throw Oops(); } catch (e) { print("code {} at line {}", e.code, e.line); }
output
Array index 7 out of bounds for array of size 3 (line 2, value null)
stack depth: 2
code 7 at line 11

What a catch can see, and what never reaches one​

The runtime's own errors are ordinary catchable values: a bad index, a division by zero, a failed cast, and a type mismatch the checker could not see coming because the value came out of a var:

jaiscript
auto xs = [1, 2, 3];
try { xs[5]; } catch (e) { print("caught: {}", e); }
try { print(10 / 0); } catch (e) { print("caught: {}", e); }
var v = "seven";
try { int n = v; } catch (e) { print("caught: {}", e); }
try { cast<char>(300); } catch (e) { print("caught: {}", e); }
output
caught: Array index 5 out of bounds for array of size 3
caught: Division by zero
caught: Cannot initialize 'n' (declared int) with string
caught: Cannot cast 'int' to 'char'

Write the same mistake with a literal and no catch will ever see it. The checker (always on, strict) reads the whole program before anything runs, so a string literal heading for an int is a load error: the program does not start, and the try around it is just text.

jaiscript
try {
    int n = "seven";
} catch (e) {
    print("never printed");
}
output
jaiscript: static check failed:
<script>:2:9: error: cannot initialize 'n' declared 'int' with 'string'
        int n = "seven";
            ^

The rule in one line: a mismatch the checker can see (a literal, a typed local, a typed return) fails the load; a mismatch it cannot see (a var-sourced value) raises where it lands and is catchable there. The Type Ladder walks through both halves, and Types That Fail Early collects the whole story. The third kind of error (the terminal kind) is next.

Terminal errors: execution budget & memory cap are UNCATCHABLE by design​

Every execute()/coroutine next() call has a wall-clock budget: default 5.0 seconds (engine::execution_budget(seconds); 0 disables). When it trips, the error is terminal: script catch handlers are skipped all the way out, so a runaway script can never swallow its own kill signal and livelock the host.

jaiscript
// engine.execution_budget() defaults to 5.0 seconds per execute()
try {
    while (true) { }
} catch (e) {
    print("this never prints: budget errors are TERMINAL, catch is skipped");
}
output
== error: Script execution budget exceeded - raise engine::execution_budget or break up the work

(The catch body did not run. The error went straight to the host.) Trying this from a terminal? The standalone runner lifts the budget by default, so give it one back: jaiscript --budget=1 runaway.jai.

engine::memory_cap(bytes) works alongside it for allocation bombs: the first raise is catchable (the growth is denied), but a second raise in the same execute is terminal. Both limits re-arm per execute(), and neither can be swallowed from inside a function, lambda, or coroutine. The terminality is the point.