RFC 016: loop and break <value> (Loop Expressions)¶
- Status: Implemented
- Created: 2025-12-24
- Author(s): Danny Meijer (@dannymeijer)
- Related: RFC 006 (generators)
- Issue: https://github.com/dannys-code-corner/incan/issues/327
- RFC PR: https://github.com/dannys-code-corner/incan/pull/399
- Written against: v0.1
- Shipped in: v0.3
Summary¶
Add a loop: keyword for explicit infinite loops and extend break to optionally carry a value (break <expr>), so loop: can be an expression that produces a value (like Rust’s loop { ... }) while while remains the general conditional loop construct.
Motivation¶
Today, users express infinite loops as while True:. The compiler may emit Rust loop {} for that pattern, but the source language has no explicit infinite-loop construct and cannot express “break with a value.” Adding loop: and break <value> gives clearer intent (loop: reads as an infinite loop), a foundation for expression-oriented control flow without “initialize then mutate” patterns, and a natural shape for “search until found” loops that return a value.
Goals¶
- Introduce
loop:as an explicit infinite loop construct. - Allow
breakto optionally carry a value:break <expr>. - Allow
loop:in expression position (e.g., assignment RHS). - Keep existing
break(no value) valid and well-defined. - Keep
while True:valid (and optionally desugar betweenloop:andwhile True:in the implementation).
Non-goals¶
- Labeled
break/continuesyntax (follow-up RFC). breakwith multiple values or tuple sugar (callers return tuples explicitly).- Making
whilean expression (value-yielding loops stay scoped toloop:).
Guide-level explanation (how users think about it)¶
loop:¶
loop:
# body
...
break (with optional value)¶
break
break some_expr
Compute a value without external mutation¶
answer = loop:
if some_condition():
break 42
Equivalent with while True: without break <value> typically uses a mut accumulator and break after assignment.
Search until found¶
found = loop:
item = next_item()
if item.is_ok():
break item
break without a value¶
loop:
if done():
break
Async and await¶
await pauses an async def until a Future is ready; it does not replace loops. They compose naturally:
import std.async
async def wait_until_done() -> None:
loop:
if await done():
break
from std.async.time import sleep
async def wait_with_backoff() -> None:
loop:
if done():
break
await sleep(0.01)
return
Reference-level explanation (precise rules)¶
Loop execution¶
loop:runs its body repeatedly until it exits viabreak(or an error/abort).continueskips to the next iteration (existing behavior).
break values¶
breakexits the innermost enclosingloop:.- If
breakincludes a value, that value is the value of theloop:expression. breakwithout a value is equivalent tobreak ()(the loop’s value isUnit).
Expression result type¶
loop: is an expression with a single result type:
- If every reachable
breakomits a value, the loop’s type isUnit. - If any reachable
breakcarries a value, the loop’s type is the least upper bound (unification) of all break value types; if the compiler cannot unify them, it must report a type error. - If a
loop:has no reachablebreak, the loop is non-terminating on those paths. Until the language defines a bottom (Never/!) type, the typechecker must reject suchloop:expressions where a concrete type is required (see Design Decisions).
Generators (yield)¶
breakexits a loop;yieldproduces one element of anIteratorand suspends (RFC 006).- Inside a generator,
loop:behaves like any other loop:yieldsuspends;breakexits the loop and may leave the generator running after the loop or finishing, depending on control flow after the loop. - When
loop:appears as an expression in a generator body,break <value>completes the loop expression only; it does not contribute to the iterator’s yielded sequence (onlyyielddoes). This is allowed and orthogonal to generator output (see Design Decisions).
Backend alignment¶
The language must be able to lower value-carrying break together with loop: to the host pattern where an infinite loop is the construct that yields a value via break (as in Rust). A lowering that only has conditional while loops may need a dedicated representation for “infinite loop with value-carrying break” so the backend can emit the correct shape.
Design details¶
Why keep loop: / break <value> when while exists¶
loop:supports multiple exit points (success, timeout, error) without extra state variables.break <value>makes the loop expression-oriented:found = loop: ... break valuewithout a pre-declaredmutholder.- A purely conditional
whileoften forces pre-initialization or a “run once then test” shape thatloop:avoids.
Example: conditional while alternative¶
item = next_item()
while not item.is_ok():
item = next_item()
found = item
This works when the loop is “repeat until condition,” but not always when the first iteration shape differs or there are multiple exits.
Alternatives considered¶
- Only
while True: -
Rejected: harder to justify
break <value>onwhile, and less clear intent for infinite loops. -
Make
whilean expression too - Rejected: larger semantic surface and surprise (“
whileyields a value?”); weaker alignment with common backend shapes for value-carrying breaks.
Drawbacks¶
- Adds a new keyword and parsing surface.
- Unifying all
breakvalue types can produce dense type errors when branches disagree. - Generator and async bodies add control-flow combinations that implementers and tests must cover.
Layers affected¶
- Lexer / parser:
loopkeyword,loop:blocks, optional expression afterbreak. - Typechecker: treat
loop:as an expression; unify break value types; rules for non-terminating loops until a bottom type exists. - Lowering / IR / emission: represent infinite loops and value-carrying
breakso the backend can emit the correct infinite-loop + break-value pattern. - Formatter / LSP (as applicable): formatting and keyword-aware tooling for
loopand extendedbreak.
Implementation Plan¶
- Add
loop:andbreak <value>support to the parser, AST, formatter, and keyword/tooling registries so the syntax is recognized consistently in statement and expression position. - Extend typechecking so
loop:expressions infer a result type from reachablebreakvalues, rejectbreak <value>outsideloop:, and reject non-terminatingloop:expressions where a concrete value is required. - Lower loop expressions and value-carrying breaks through IR and emission so the backend preserves loop-result semantics instead of desugaring them away incorrectly.
- Cover the feature with parser, typechecker, formatter, and codegen tests, then update the control-flow docs and release notes for the release line that eventually ships the feature.
Implementation log¶
Parser / AST / Formatter / Tooling¶
- Add
loopto the keyword registry and syntax highlighting. - Parse
loop:in statement position and expression position. - Extend
breakparsing to accept an optional value. - Add formatter support for
loop:andbreak <value>. - Keep AST/LSP/frontend walkers aligned with the new nodes.
Typechecker¶
- Infer
loop:expression result types from reachablebreakvalues. - Reject
break <value>outsideloop:. - Reject
loop:expressions with no reachablebreakwhen a concrete type is required. - Keep
continuediagnostics and ordinary statement-loop behavior aligned with the new loop context rules.
Lowering / IR / Emission¶
- Represent
loop:and value-carryingbreakexplicitly in IR. - Lower loop expressions and
break <value>through the backend without losing semantics. - Emit correct Rust
loop { ... break value; }shapes for loop expressions.
Tests¶
- Add a parser unit test for
loop:withbreak <value>. - Add typechecker tests for valid loop expressions and invalid
break <value>usage. - Add a formatter round-trip test for
loop:withbreak <value>. - Add a codegen snapshot for loop expressions.
- Add an end-to-end runtime/integration test for loop expressions.
Docs / Release Notes¶
- Update the control-flow docs to explain
loop:andbreak <value>. - Add release notes coverage for the release line that ships this feature.
Design Decisions¶
-
Non-terminating
loop:(no reachablebreak) — Until the language defines a bottom (Never/!) type, aloop:used as an expression where a concrete type is required must be rejected if there is no reachablebreak. IntroducingNeveris explicitly out of scope for this RFC’s minimum bar; a follow-up may add it and relax this rule. -
Labeled
break/continue— Deferred to a separate RFC (see Non-goals). -
Statement-only vs expression
loop:— Both forms are in scope:loop:may appear as a statement or as an expression (per Goals), not phased as statement-only first. -
loop:as an expression inside generator bodies — Allowed.break <value>only completes the innerloop:expression; iterator consumers still observe output only throughyield.
Possible future syntax sugar: loop ... until ...¶
A compact statement-level sugar for “repeat an action until a condition holds” may be added later:
loop item.next() until item.is_ok()
Conceptual desugaring:
loop:
item.next()
if item.is_ok():
break
until <expr>must typecheck tobool.- This form is intended as a statement and does not yield a value by itself.