Skip to content

Control Flow

This chapter documents Zynx statement boundaries, match, and cleanup behavior for source-level exits.

Status: current public API unless a rule is marked unsupported or unspecified.

Statements And Semicolons

A block is { ... } and contains zero or more statements.

Normal completion of a block, if, or loop is checked as the single value void(), but these constructs remain statement-only syntax. They cannot appear where a value expression is required, and the final expression in a block is never returned implicitly. Produce a value through => expression, an explicit destination assigned on all paths, or a function/closure with explicit return.

These statement forms require a trailing semicolon:

  • let, var, and const declarations
  • return
  • throw
  • assert
  • break
  • continue
  • asm
  • ordinary expression statements

These statement forms do not take a trailing semicolon:

  • defer
  • if
  • while
  • loop
  • for
  • with
  • nested block statements

Statement-form match and statement-form block catch may be written with or without a trailing semicolon.

A statement-position match occupies a whole statement:

Zynx
match value {
	.A => { },
	.B => { }
}

A value-position match uses => expression arms:

Zynx
let code = match value {
	.A => 1,
	.B => 2,
};

Statement-form block catch means the whole statement is an error-handling expression with catch arms:

Zynx
fallible() catch {
	_ => { }
}

Both forms accept an optional semicolon after the closing brace. When catch appears inside a larger statement, the outer statement still follows its own semicolon rule:

Zynx
let value = fallible() catch { _ => 0 };

In statement position, match and block catch arms can use a block body or => expression; expression-arm results are discarded. In value position, every non-diverging arm uses => expression. A block arm completes as void(): its trailing expression is never an implicit value, with or without a semicolon. Such a block is legal as a statement arm, or in value position only when it diverges through return, throw, or another terminal operation. Commas and semicolons are accepted as arm separators. In statement-form match, an arm that exits with return, break, continue, throw, or failed try does not contribute moved or borrowed state to the code after the match. Later source is still analyzed, so type errors in unreachable code are reported normally.

Boolean Conditions And Loops

The condition of if and while must have type exactly scalar bool. Integers, pointers, nullable values, bool masks, enums, aggregates, and other values have no truthiness and do not convert implicitly to bool. State the intended test explicitly, for example if count != 0 { ... } or while ptr != null { ... }.

loop is the canonical, warning-free unconditional infinite loop. while true and while with another compile-time constant scalar-bool condition whose value is true remain well-typed and legal. A style lint may suggest loop for those forms; it does not change their acceptance and is never emitted for loop itself.

Loop bodies are analyzed as potentially repeating. Moving a move-only value, or a field or element of one, inside a loop is rejected when the next iteration would need the moved source again. A borrow created inside the body must be released before the back edge unless it is part of the loop's continuing state. For for x in collection, a structural iter(self) cursor normally holds a shared collection loan for the loop body; mutating the collection during iteration is rejected, while compatible shared read-only access is allowed. A source that is already a cursor follows the direct-cursor rule below instead.

Cursor and source selection

The loop expression is evaluated once. A type with an accepted synchronous cursor-shaped next is a direct cursor for for: the loop calls that next and never calls the type's iter, even if both names exist. Direct cursor classification wins; a missing or redundant try, or an incompatible lending binding, is diagnosed without falling back to iter. An unrelated method merely named next but matching no accepted cursor shape does not block structural source resolution.

A named direct cursor is exclusively borrowed for the complete loop. It is not moved, copied, restarted, or replaced by a fresh cursor. The name is unavailable inside the loop, but break ends the loop loan and preserves the exact current position:

Zynx
let cursor = values.iter();

for value in cursor {
	inspect(value);
	break;
}

// Resumes the same cursor after the first yielded value.
for value in cursor {
	inspect(value);
}

Normal exhaustion is sticky. After a next call returns null, every later call returns null as an exact observational no-op: it does not touch an underlying source, invoke user code, allocate or deallocate, perform I/O or entropy work, mutate observable state, or throw. A lending null carries no loan. This rule applies equally to direct calls and calls generated by for.

Only a type without a direct synchronous next shape is treated as a source. The compiler resolves one concrete structural iter overload from the written role: source selects iter(self), &source selects iter(&self), and <-source selects iter(^self). It acquires one temporary cursor before the first pull. There is no Iterable/IntoIterator source interface, implicit cursor copy, clone, or fallback receiver. Stateful standard cursors are affine, non-Copy, non-Clone, and expose next rather than another iter.

str follows this same structural road; it has no dedicated backend loop lowering. str.iter() produces an allocation-free affine RuneCursor tied to the counted source and next() yields rune?. The cursor advances to its stored byte end, so an embedded U+0000 is an item rather than an exhaustion sentinel. Grapheme clusters require explicit std.unicode.grapheme.iter(text).

Mutable lending iteration writes both exclusive roles:

Zynx
for item: &Entry in _ in &entries {
	item.enabled = true;
}

item: &Entry in _ is the explicit local item loan, and &entries alone selects the collection's iter(&self) overload. The source is exclusively loaned for the whole loop; each successful next lends one fresh item only for that iteration and the loan ends before the next backedge. The collection name cannot be read, borrowed again, or structurally mutated inside the body. Fields and the complete current item may be replaced through item; replacement drops the old value. The item cannot be moved with <-, returned, stored outside the iteration, or captured by an escaping closure, task, Future, or Generator. Ordinary nonescaping calls and reborrows are allowed.

There are no aliases: for &item in entries, for item in &entries, for item: &Entry in _ in entries, iter_mut, and mutable tuple destructuring are rejected.

Some collections expose an exclusive projection rather than a mutable loan of their complete stored element. A concrete cursor may structurally provide:

Zynx
struct View<region Step> {
	let read: Key in Step,
	let write: &Value in Step,
}

fn next<region Step>(&self in Step) -> View? in Step;

Its loop binding is an explicitly region-typed affine view:

Zynx
for view: View in _ in &collection {
	inspect(view.read);
	view.write.enabled = true;
}

This is a general structural lending rule with no recognized collection or view name. The source-side &collection selects only iter(&self); neither the View in _ binding nor later use selects an overload. Every non-null result is tied to the fresh iteration step. An eligible view contains only shared or exclusive loans plus copy metadata; it has no owned field and no destructor. The loop may use only the roles stored in the view, which must end before the next backedge. It cannot be moved out, returned, stored beyond the step, or captured by escaping work. A null result carries no loan. std.iter.ExclusiveIterator<T> remains the smaller interface for the ordinary complete-element &T case.

Consuming collection iteration writes the move on the source:

Zynx
for item in <-items {
    consume(<-item);
}

This selects only iter(^self) and moves items before the loop. There is no fallback to shared iteration, copy, or clone, and for item <- items is rejected. A fresh owned temporary such as try make_items() needs no <-. Each yielded item is an ordinary owner. continue drops an unmoved current item; break, return, and checked failure then drop the remaining armed tail in ordinary reverse collection order and free storage once. Normal exhaustion frees storage once, no exit recovers a remainder, and a runtime trap performs no cleanup.

That cleanup describes the loop-owned temporary cursor created by for item in <-items. A separately named owning cursor is different:

Zynx
let cursor = (<-items).iter();
for item in cursor {
	use(item);
	break;
}
// `cursor` still owns the unyielded tail and can be resumed.

The direct loop only releases its exclusive cursor loan on break; the named cursor drops its residual owners and storage later at its own owner boundary. Writing <-cursor as the loop expression explicitly moves that cursor into the loop instead, so an early exit drops its remainder there.

Plain for x in source is synchronous and requires infallible next() -> T?. A synchronous iterator with next() -> T? throws(E) requires for try x in source. The header propagates each next() failure before the binding or body is entered, and the enclosing checked result must admit E. Missing try and redundant try are both hard errors. A direct fallible next never falls back to an infallible iter overload.

Zynx
fn scan() throws(ParseError) {
	let tokens = try open_tokens(); // separate acquisition try

	for try token in <-tokens {     // repeated next() may fail
		consume(<-token);
	}
}
Zynx
for token in fallible_tokens() {}
// error: fallible next() requires `for try`

for try number in 0..<10 {}
// error: infallible next() rejects redundant header `try`

The source expression and any structural cursor acquisition are evaluated once. Header try covers only repeated next(), never a fallible source expression or iter() construction; write that acquisition try separately. If next() fails, the current body does not start, the cursor and exited scope state are cleaned exactly once when their owner boundaries are exited, and the checked error propagates. A checked error is not a null exhaustion result. To handle a failure locally and retain a named cursor when its concrete contract permits recovery, write an explicit loop and call next() catch { ... }. There is no for catch.

The parser reserves for await x in source and for try await x in source, but the current language defines no eligible async multi-item source. A method named async_iter() or next() returning a Future does not create an async stream or revive Awaitable. for await try is rejected; if a later RFC defines async iteration, try await is the sole effect order.

Inside [try] await with async.Scope..., breaking a nested synchronous loop does not close the Scope; reaching the Scope boundary still cancel-and-joins every remaining child.

Match

match expr { ... } is supported for bool, enums, integers, and strings. It may be a statement or produce a value from explicit => expression arms. References are not auto-dereferenced; write match *ref { ... } when matching the referenced payload.

Enum patterns use .Variant and can bind payload variables with .Variant(a, b). ._ is not a pattern; use _ for the wildcard. Integer and string matches use literal patterns. Non-enum matches require a _ wildcard.

In statement position an arm body may be a block or an expression. To produce a match value, every reachable non-diverging arm must use => expression; a non-diverging block arm cannot provide the value through its last expression. Multi-statement value logic belongs in a named helper or immediately invoked closure that uses explicit return:

Zynx
let code = match value {
	.A => (() -> i32 {
		log_a();
		return 1;
	})(),
	.B => 2,
};

For statement control flow, code may instead assign to an explicitly declared destination in every arm or return from the enclosing function. try may be used inside an arm; on failure it propagates through the enclosing function by the normal error-union rules.

Enum matches are checked for exhaustiveness unless a wildcard arm is present. For integer and string matches, a wildcard is required.

Exit And Cleanup Semantics

Zynx distinguishes language exits from runtime traps. Language exits unwind source scopes and run cleanup. Runtime traps abort immediately and do not unwind. Zynx has no separate unwinding panic: the only abnormal runtime termination is a trap, and a trap always aborts the process rather than unwinding. Where other languages would "panic", Zynx traps, with the cleanup-skipping behavior described below.

For every source scope that is exited by a language exit, cleanup order is:

  1. Run defer bodies registered in that scope in last-in, first-out order.
  2. Run automatic drops for locals whose lifetime ends at that scope in reverse cleanup-registration order.
  3. Transfer control to the exit destination.

Automatic drop is lexical and cannot be moved earlier by liveness analysis. Locals drop in reverse declaration order after the block's registered defers, even if their last use occurred earlier. A value moved out, returned, or consumed by std.drop(value) / @drop(value) has transferred or discharged its obligation and therefore has no later drop at its origin.

For example, if Tracer.drop prints drop:

Zynx
{
	let item = Tracer(1);
	_ = item.id;
	defer io.println("defer");
}

The registered defer prints first; item then drops at the lexical block boundary. An optimizer may remove dead pure work, but it may not move this observable cleanup earlier.

defer does not capture by value. Its body is checked as code that will execute later during cleanup. If a registered defer reads or moves a value, later code cannot move that value in a way that would make the deferred body use a moved source. Multiple defer bodies in one scope run last-in, first-out, and each deferred body is checked against the ownership state left by later-registered defers that run before it.

Ordinary defer is synchronous; only defer await may suspend. Both cleanup boundaries must return normally and cannot replace the primary outcome already selected before cleanup. Direct outward return, escaping throw, a failed or propagating try, and break/continue whose target is outside the cleanup body are compile errors. Every checked failure must be caught and completely handled inside cleanup. Internal loops and matches may use break/continue when their target is also inside the body. A called function or invoked closure has its own function boundary, so its internal control flow returns normally to cleanup. A runtime trap is different: it aborts immediately and does not run remaining defers or lexical drops.

Ordinary with expr as name { body } is removed and is a hard error. Write an explicit block with let name = expr;, any explicit defer, and the body. This evaluates the initializer before the body, then runs LIFO defers and lexical local drops on every language exit. There is no enter/exit or context-manager protocol. The only retained with spelling is the complete RFC 0009 composite [try] await with async.Scope.local/parallel() as scope { ... }, whose close cancel-and-joins children rather than performing ordinary local drop.

The exit matrix is:

Exit kindCleanup behavior
Normal block fallthroughRuns that block's registered defer bodies, then automatic drops for locals ending at the block.
returnEvaluates the return value first, then unwinds every exited scope before returning to the caller or completing the async task.
break / continueUnwinds scopes exited by the jump up to the target loop boundary, then transfers control to the loop exit or next iteration. Moved and borrowed state from reachable loop exits is merged at the target.
throw ErrorSet.VariantBuilds the error value, then unwinds every exited scope before returning the error from the current function.
try expr failureCopies the failure from expr, then unwinds like throw before propagating the error from the current function.
try expr successUnwraps the success value and does not run cleanup beyond normal temporary cleanup.
expr catch { ... } successYields the success value and does not evaluate any catch arm.
expr catch { ... } failureEvaluates only the selected catch arm. Only => expression produces the catch value. A block arm is void, regardless of its trailing expression, and is legal in value position only when it diverges. return exits the enclosing function. Arm-local defer bodies and drops run before the selected arm yields or exits. break, continue, throw, or a failed try follow the normal language-exit rules.
defer / defer await bodyRuns only during cleanup after registration. Ordinary defer is synchronous; defer await is the explicit suspending form. Both are infallible and normally returning: checked errors must be handled locally, and no return/error/nonlocal-loop outcome may escape. Internal control stays legal. If cleanup traps, the trap aborts immediately and remaining cleanup does not run.
discard self;In an original concrete struct ^self method, ends the receiver without invoking its user drop(&self), then immediately drops all still-initialized residual fields in reverse declaration order. It does not exit the function; extracted values remain usable and self does not. Every non-diverging path in such a method must explicitly resolve the receiver. A partial receiver cannot cross failure, suspension, nonlocal control, or deferred cleanup.
[try] await with async.Scope.<mode>() as scope { ... }Opens the RFC 0009 Scope, runs the body, then cancel-and-joins every child before the lexical region ends on fallthrough or any language exit. This is one composite construct, not ordinary with.
Automatic dropRuns at the lexical boundary for a live owned value unless its obligation was moved or explicitly consumed. It runs after that block's registered defers and in reverse declaration order.
Async cancellationCancellation is cooperative lifecycle completion. It is observed at cancellation checks and suspension boundaries; masked cleanup and automatic drops complete before the task becomes terminal. Scope close cancel-and-joins every child before returning.
Runtime trapPrints the trap diagnostic when one is available, then aborts immediately. It does not run defer bodies, automatic drops, Scope close, or async cancellation cleanup.

Runtime traps include bounds failures, checked arithmetic overflow, division by zero, invalid dynamic-call entry points, and other safety checks that are not represented as throws(...) errors.

Because a trap skips every drop, defer, and Scope cleanup, any effect that depends on running that cleanup does not happen. The resource-safety implications are:

  • Process-local resources are reclaimed by the operating system at process termination, not by Zynx. Heap allocations and open file descriptors held only through a skipped drop are released by the OS when the aborted process exits.
  • Effects that exist only inside a drop or defer body do not occur. Buffered writes are not flushed, locks visible to other processes or threads are not released, and transactional commits or rollbacks staged in cleanup do not run.
  • Therefore a trap is sound for process-local memory safety but is not a recovery mechanism. Code that must preserve an invariant across abnormal termination must not rely on drop or defer; traps are reserved for unrecoverable bugs and abort the whole process.

Guaranteed Tail Calls

@tailcall(f(args)); is a statement that is the enclosing function's exit: it calls f(args) as a guaranteed tail call and returns its result. The compiler either emits a real machine tail call, so a chain of such calls runs in constant stack space, or it rejects the program with a diagnostic. It is never a silent best-effort optimization.

Zynx
fn count_down(n: i32, acc: i32) -> i32 {
	if n == 0 { return acc; }
	@tailcall(count_down(n - 1, acc + 1));
}

count_down(1_000_000, 0) completes without growing the stack; written with a plain recursive return, the same call overflows the stack.

Because Zynx runs defer bodies and automatic drops as part of a function exit, most syntactic "tail" positions are not real tail calls: cleanup has to run after the call returns. @tailcall makes the requirement explicit and checked rather than guessing. It is accepted only when nothing runs after the call:

  • The operand is a direct call, written in statement (exit) position.
  • The callee's return type is identical to the enclosing function's; a guaranteed tail call cannot convert, wrap, or coerce the result.
  • No cleanup is pending at the call: no live owned local and no registered defer remain in the enclosing scope. A local that would otherwise drop after the call must be moved or consumed first, or the call cannot be a tail call.
  • Neither the enclosing function nor the callee is async; async returns run through the state machine, not a machine ret.

The result type must also be one the target can tail-call-eliminate: a scalar, void, a by-value aggregate up to 16 bytes, a payload-enum, or an error union whose success value is itself one of those. Wider by-value aggregates, str and [T] are rejected, because they cannot transfer ownership across the jump.

A fallible callee is allowed when the enclosing function's throws(...) set covers the callee's — every error the callee can produce is a legal member of the caller's error union — so the error is returned verbatim with no remap at the jump:

Zynx
error Parse { Bad }

fn scan(pos: i32, limit: i32) throws(Parse) -> i32 {
	if pos >= limit { throw Parse.Bad; }
	if pos == limit - 1 { return pos; }
	@tailcall(scan(pos + 1, limit));
}

Each rejected case reports why, so the ownership rules turn into a guarantee rather than a surprise. A guaranteed tail call replaces the caller's native stack frame rather than stacking it beneath the callee. Native unwind frame presence is not a language guarantee; logical traces follow the contract in Errors.

Released under the MIT License.