Control Flow
Overview
rill provides singular control flow with no exceptions and no try/catch. Errors halt execution. guard captures halts as invalid values for explicit recovery. retry retries a body when it returns an invalid result.
| Syntax | Description |
|---|---|
cond ? then ! else | Conditional (if-else) |
$val -> ? then ! else | Piped conditional (uses $ as cond) |
while (cond) do { body } | While loop (cond is bool, evaluated before each iteration) |
do { body } while (cond) | Do-while (body first, cond after) |
do<limit: N> { body } | Construct option: sets iteration limit |
break / $val -> break | Exit loop |
return / $val -> return | Exit block |
assert cond / assert cond "msg" | Validate condition, halt on failure |
error "msg" / $val -> error | Halt execution with error message |
guard { body } | Run body; replace halt with invalid value |
retry<limit: N> { body } | Retry body up to N times on caught halt |
timeout<total: duration> { body } | Bound body to a wall-time limit; expiry → #RILL_R082 |
timeout<idle: duration> { body } | Bound body to an inactivity limit; expiry → #RILL_R083 |
Conditionals
? is the conditional operator. The condition precedes ?, and ! introduces the else clause.
Syntax Forms
condition ? then-body
condition ? then-body ! else-body
$val -> ? then-body ! else-body # piped form: $ is the condition
# Multi-line forms (? and ! work as line continuations)
condition
? then-body
! else-body
value -> is_valid
? "ok"
! "error"Standalone Form
Condition precedes ?:
true ? "yes" ! "no" # "yes"
false ? "yes" ! "no" # "no"
(5 > 3) ? "big" ! "small" # grouped comparison as conditionPiped Form
Use $ as condition:
true -> ? "yes" ! "no" # "yes" (pipe value must be bool)
5 -> ($ > 3) ? "big" ! "small" # "big"Method Conditions
Methods that return booleans work directly as conditions:
"hello" -> .contains("ell") ? "found" ! "missing" # "found"
"abc" -> !.empty ? "has content" ! "empty" # "has content"Condition Forms
"test" -> ($ == "test") ? "match" ! "no" # grouped comparison
"test" -> .eq("test") ? "match" ! "no" # comparison method
"xyz" -> .contains("x") ? "found" ! "no" # method as conditionOptional Else
The else branch (! ...) is optional:
true ? "executed" # only runs if true
false ? "skipped" # returns empty stringElse-If Chains
"B" => $val
$val -> .eq("A") ? "a" ! .eq("B") ? "b" ! "other" # "b"Multi-line else-if chains improve readability:
"B" => $val
$val -> .eq("A") ? "a"
! .eq("B") ? "b"
! "other"
# Result: "b"Return Value
Conditionals return the last expression of the executed branch:
true -> ? "yes" ! "no" => $result # "yes"
false -> ? "yes" ! "no" => $result # "no"Block Bodies
Use braces for multi-statement branches:
true -> ? {
"step 1" -> log
"step 2" -> log
"done"
} ! {
"skipped"
}Block bodies work with multi-line conditionals:
"data" => $input
$input -> .empty
? { error "Empty input" }
! { $input -> .upper }
# Result: "DATA"While Loop
Pre-condition loop. Condition is evaluated before each iteration. The body result becomes the next iteration’s $.
Loop bodies cannot modify outer-scope variables. Use $ to carry all state. For multiple values, pack them in a dict.
Syntax
while (condition) do { body }
initial -> while (condition) do { body }Basic Usage
# Count to 5
0 -> while ($ < 5) do { $ + 1 }
# Result: 5# String accumulation
"" -> while (.len < 5) do { "{$}x" }
# Result: "xxxxx"Condition Forms
0 -> while ($ < 10) do { $ + 1 }
# Result: 10"" -> while (.len < 4) do { "{$}x" }
# Result: "xxxx"Pipe-Seeded While Loop
Pipe a seed value to while to prime the accumulator $:
0 -> while ($ < 3) do { $ + 1 }
# Result: 3"start" -> while ($ != "done") do {
($ == "start") ? "middle" ! "done"
}
# Result: "done"Infinite Loop with Break
0 -> while (true) do {
$ + 1 -> ($ > 5) ? break ! $
}
# Result: 6Iteration Limit
Use do<limit: N> as the construct option to set maximum iterations (default: 10,000):
0 -> while ($ < 10) do<limit: 100> { $ + 1 }
# Result: 10Exceeding the limit throws RuntimeError with code RUNTIME_LIMIT_EXCEEDED.
The do<limit: N> construct option applies only to that specific loop evaluation, not to the body closure.
Multiple State Values
When you need to track multiple values across iterations, use $ as a state dict:
use<ext:app> => $app
# Track iteration count, text, and done flag
[iter: 0, text: $input, done: false]
-> while (!$.done && $.iter < 3) do {
$.iter + 1 => $i
$app.process($.text) => $result
$result.finished
? [iter: $i, text: $.text, done: true]
! [iter: $i, text: $result.text, done: false]
}
# Access final state: $.text, $.iterThis pattern replaces the common (but invalid) approach of trying to modify outer variables from inside the loop.
Do-While Loop
Post-condition loop. Body executes first, then condition is checked. Use when you want at least one execution.
Syntax
do { body } while (condition)
initial -> do { body } while (condition)Basic Usage
# Execute at least once, continue while condition holds
0 -> do { $ + 1 } while ($ < 5)
# Result: 5# String accumulation
"" -> do { "{$}x" } while (.len < 3)
# Result: "xxx"Pipe-Seeded Do-While Loop
Pipe a seed value to prime the accumulator $ before the first iteration:
0 -> do { $ + 1 } while ($ < 3)
# Result: 3"" -> do { "{$}!" } while (.len < 4)
# Result: "!!!!"When to Use
- While
while (condition) do { body }: condition checked BEFORE body (may execute 0 times) - Do-while
do { body } while (condition): condition checked AFTER body (executes at least once)
Retry Pattern
Do-while is ideal for retry patterns:
use<ext:app> => $app
do<limit: 5> {
$app.prompt("Perform operation")
} while (.contains("RETRY"))
# Loop exits when result doesn't contain RETRYIteration Limit
0 -> do<limit: 100> { $ + 1 } while ($ < 10)
# Result: 10Break
Exit a loop early. Returns the value piped to break, or current $ if bare.
Syntax
break # exit with current $
$value -> break # exit with valueIn seq Loop
[1, 2, 3, 4, 5] -> seq({
($ > 3) ? ("found {$}" -> break)
$
})
# Returns "found 4"In While Loop
0 -> while (true) do {
($ + 1) -> ($ > 3) ? break ! $
}
# Returns 4Break Value
In seq, break returns partial results collected before the break:
["a", "b", "STOP", "c"] -> seq({
($ == "STOP") ? break
$
})
# Returns ["a", "b"] (partial results before break)acc also catches break and returns the partial results list collected up to that point.
Break Not Allowed
break is not supported in fan, filter, or fold:
[1, 2, 3] -> fan({ break }) # ERROR: break not supported in fanReturn
Exit a block early. Returns the value piped to return, or current $ if bare.
Syntax
return # exit with current $
$value -> return # exit with valueIn Blocks
5 => $x
($x > 3) ? ("big" -> return)
"small"
# Returns "big"Multi-Phase Pipeline
"content" => $content
$content -> .contains("ERROR") ? ("Read failed" -> return)
"processed: {$content}"
# Returns "processed: content" or "Read failed"Assert
Validate conditions during execution. Halts the script with a clear error if the assertion fails.
Syntax
assert condition
assert condition "error message"
$value -> assert conditionBasic Usage
Assert halts execution when the condition evaluates to false. If the condition is true, the piped value passes through unchanged.
5 -> assert ($ > 0) # Returns 5 (condition true)-1 -> assert ($ > 0) # Error: Assertion failedCustom Error Messages
Provide a descriptive message as the second argument:
"" -> assert !.empty "Empty input not allowed"
# Error: Empty input not allowed[1, 2, 3] -> assert (.len > 0) "List cannot be empty"
# Returns [1, 2, 3] (assertion passes)Type Assertions
Combine with type checks to validate input:
"hello" -> assert $:?string # Returns "hello" (type check passes)42 -> assert $:?string # Error: Assertion failedIn Loops
Assert validates each iteration. The loop halts on the first failing assertion:
[1, 2, 3] -> seq({
assert ($ > 0) "Must be positive"
})
# Returns [1, 2, 3] (all elements valid)[1, 0, 3] -> seq({
assert ($ > 0) "Must be positive"
})
# Error: Must be positivePipe Passthrough
When the assertion passes, the piped value flows through unchanged:
"data" => $input
$input
-> assert !.empty "Input required"
-> .upper
-> assert (.len > 0) "Processed value required"
# Returns "DATA"Error Behavior
Assert throws RuntimeError when:
| Condition | Error Code | Message |
|---|---|---|
Condition is false | RUNTIME_ASSERTION_FAILED | Custom message or “Assertion failed” |
| Condition is not boolean | RUNTIME_TYPE_ERROR | “assert requires boolean condition, got {type}” |
# Non-boolean condition
"test" -> assert $ # Error: assert requires boolean condition, got string
# Failed assertion with location
-1 -> assert ($ > 0) # Error: Assertion failedValidation Patterns
Guard clauses at function start:
|data| {
assert $data:?list "Expected list"
assert !$data.empty "List cannot be empty"
$data -> seq({ $ * 2 })
} => $process
trueMulti-step validation:
use<ext:app> => $app
$input
-> assert $:?string "Input must be string"
-> .trim
-> assert !.empty "Trimmed input cannot be empty"
-> assert (.len >= 5) "Input too short (min 5 chars)"
-> $app.process()Error
Halt execution immediately with a custom error message. Unlike assert, which validates a condition, error always halts.
Syntax
error "message" # Direct form
$value -> error # Piped formBasic Usage
Use error with a string literal to halt with a message:
error "Something went wrong"
# Halts execution with: Something went wrongThe message argument accepts string literals or piped string values (see Piped Form below).
Piped Form
Pipe a string value to error to use dynamic error messages:
"Operation failed" -> error
# Halts with: Operation failedThe piped value must be a string:
"Error occurred" => $msg
$msg -> error
# Halts with: Error occurred
"Status: " => $prefix
404 => $code
"{$prefix}{$code}" -> error
# Halts with: Status: 404Piping non-string values throws a type error:
42 -> error # Error: error requires string, got numberString Interpolation
Use interpolation for dynamic error messages:
404 => $code
error "Unexpected status: {$code}"
# Halts with: Unexpected status: 4043 => $step
"timeout" => $reason
error "Failed at step {$step}: {$reason}"
# Halts with: Failed at step 3: timeoutConditional Usage
Combine error with conditionals for guard clauses:
5 => $x
($x < 0) ? { error "Number must be non-negative" } ! $x
# Returns 5 (condition false, proceeds with else branch)$data -> .empty ? { error "Data cannot be empty" } ! $data
# Proceeds with $data if not emptyIn Blocks
Use error in blocks for multi-step validation:
|age| {
($age < 0) ? { error "Age cannot be negative: {$age}" }
($age > 150) ? { error "Age out of range: {$age}" }
"Valid age: {$age}"
} => $validate_age
trueError Behavior
Error throws RuntimeError with code RUNTIME_ERROR_RAISED:
| Pattern | Halts With | Message Source |
|---|---|---|
error "msg" | RUNTIME_ERROR_RAISED | String literal |
$val -> error | RUNTIME_ERROR_RAISED | Piped value (must be string) |
error "" | RUNTIME_ERROR_RAISED | Empty message |
error 123 | Parse error | PARSE_INVALID_SYNTAX |
All error responses include the source location from the error statement.
Multiline Messages
Use triple-quoted strings for formatted error messages:
error """
Error occurred:
- Line 1
- Line 2
"""Comparison with Assert
| Statement | Condition | Behavior |
|---|---|---|
assert cond "msg" | Validates condition | Halts if condition is false, passes through if true |
error "msg" | None | Always halts with message |
Use assert when you need to validate a condition. Use error when you’ve already determined that execution cannot continue.
In Loops
Error halts the loop immediately:
[1, 2, 3] -> seq({
($ == 2) ? { error "Halted at 2" }
$ * 2
})
# Halts on second iteration with: Halted at 2Pass
The pass keyword returns the current pipe value ($) unchanged. Use it for explicit identity pass-through in conditional branches and dict values.
In Conditionals
Use pass when one branch should preserve the piped value:
"input" -> .contains("in") ? pass ! "fallback"
# Returns "input" (condition true, pass preserves $)"data" -> .empty ? { error "Empty input" } ! pass
# Returns "data" (condition false, pass preserves $)In Dict Values
Use pass to include the piped value in dict construction:
"success" -> { [status: pass, code: 0] }
# Returns [status: "success", code: 0]In Collection Operators
Preserve elements conditionally:
[1, -2, 3, -4] -> fan({ ($ > 0) ? pass ! 0 })
# Returns [1, 0, 3, 0]Why Use Pass?
The pass keyword provides clearer intent than bare $:
# Less clear - what does $ mean here?
$cond ? do_something() ! $
# More explicit - reader knows this is intentional no-op
$cond ? do_something() ! passPass Behavior
| Pattern | Returns | Context |
|---|---|---|
cond ? pass ! alt | $ if true, alt if false | Conditional branch |
cond ? alt ! pass | alt if true, $ if false | Conditional branch |
[key: pass] | Dict with $ as value | Dict construction |
-> { pass } | $ | Block body |
Note: pass requires pipe context. Using pass without $ bound throws an error (RILL-R005).
Pass Body Forms
The body forms run a block for side effects, then pass the original pipe value through unchanged. The block’s result is always discarded.
| Form | Suppresses catchable halts? |
|---|---|
pass { body } | No — halts in body propagate normally |
pass<on_error: #IGNORE> { body } | Yes — catchable halts in body are suppressed; the pipe value flows through |
5 -> pass { log($) }
# Logs 5; result is 5range(1, 4) -> seq({ $ * 10 }) -> pass { log($) }
# Logs the list; result is [10, 20, 30]10 -> pass<on_error: #IGNORE> { 1 / 0 }
# Result: 10 (body halt suppressed)on_error accepts only #IGNORE. Empty pass<>, unknown option keys, and any other on_error value raise RILL-P004 at parse time. Non-catchable halts (error, assert) and ControlSignal (break, return) always propagate out of either body form. See Collection Slicing for the full reference.
Operational Recovery with guard
guard { body } executes its body. When the body halts (via error, assert, or a throwing extension call), guard replaces the halt with an invalid value instead of stopping execution. The invalid value carries a :atom value identifying the failure.
use<ext:app> => $app
guard { $app.fetch("https://api.example.com/data") } => $result
$result.? ? $result.data ! error "fetch failed: {$result.!}"The .? operator tests whether $result is valid. The .! operator extracts the :atom value when it is not.
Invalid Value
An invalid value is a first-class rill value that carries a :atom value. It is not an exception. It propagates through pipes and dict fields without halting, until an explicit check uses .? or .!.
use<ext:app> => $app
guard { $app.risky_call() } => $r
$r.! # :atom value (e.g. #TIMEOUT, #AUTH, #ok if valid)
$r.? # bool: true if validUsing an invalid value as an operand in arithmetic or type assertions halts execution. Test with .? before use.
See Error Handling for full patterns and Error Reference for pre-registered atoms.
Bounded Retry with retry<limit: N>
retry<limit: N> { body } runs its body up to N times. Each attempt that raises a catchable halt triggers the next attempt. The result is the first successful value, or the final invalid value when all attempts halt.
use<ext:app> => $app
retry<limit: 3> { $app.fetch("https://api.example.com/data") } => $resultretry is strictly bounded; N must be a literal integer. retry catches the same catchable halts as guard; non-catchable halts (error, assert) propagate without retry.
use<ext:app> => $app
retry<limit: 3> {
guard { $app.fetch("https://api.example.com/data") }
} => $result
$result.? ? $result ! error "all retries failed: {$result.!}"When to Use retry vs. do-while
| Pattern | Use when |
|---|---|
retry<limit: N> { guard { ... } } | Fixed maximum attempts, automatic backoff not needed |
do { } while (cond) | Custom retry logic, delay, or condition-based exit |
See Error Handling for retry with backoff patterns.
Timeout Blocks
Timeout blocks bound the execution time of a body. On expiry, the body is cancelled and the block raises a catchable halt. Wrap with guard to convert the halt into an invalid value, then use ?? for a fallback.
Two forms are available:
| Form | Bounds | Expiry atom |
|---|---|---|
timeout<total: duration> { body } | Wall-time from block entry | #RILL_R082 |
timeout<idle: duration> { body } | Inactivity: time since last stream chunk | #RILL_R083 |
total and idle are mutually exclusive. Specifying both in one head is a parse error.
Wall-Time Bound (total)
timeout<total: duration(0, 0, 0, 0, 0, 0, 500)> {
app::fetch("https://api.example.com/data")
}The body must finish within 500 ms. If it does not, the block aborts the body and raises a catchable halt carrying #RILL_R082. Wrap with guard (see below) to convert it into an invalid value.
Inactivity Bound (idle)
timeout<idle: duration(0, 0, 0, 0, 0, 0, 200)> {
$stream -> seq({ $ })
}The idle timer resets each time the body emits a stream chunk. If no chunk arrives within 200 ms, the block aborts and raises a catchable halt carrying #RILL_R083. Wrap with guard to surface the halt as an invalid value.
For non-streaming bodies, idle behaves like total: the timer fires if the body does not complete within the idle window.
Duration Argument
The duration expression must evaluate to a duration value. A non-duration argument halts with #INVALID_INPUT:
# Error: #INVALID_INPUT — duration must be a duration value
timeout<total: 500> { "x" }Construct a duration using the duration() built-in:
# 500 ms: duration(years, months, weeks, days, hours, minutes, ms)
duration(0, 0, 0, 0, 0, 0, 500) => $d_500msRecovery via guard
Wrap the timeout block with guard to catch expiry as an invalid value:
guard {
timeout<total: duration(0, 0, 0, 0, 0, 0, 500)> {
app::fetch("https://api.example.com/slow")
}
} => $result
$result.! ? "timed out" ! $resultUse ?? after the guard expression for a simple fallback:
guard {
timeout<total: duration(0, 0, 0, 0, 0, 0, 500)> {
app::fetch("https://api.example.com/slow")
}
} ?? "fallback response"timeout without guard still produces a catchable halt. Direct use of ?? on a timeout<> expression does not intercept expiry halts. Always wrap with guard first.
Nesting
Timeout blocks may be nested. The outer timer fires independently of the inner timer:
timeout<total: duration(0, 0, 0, 0, 0, 0, 1000)> {
timeout<total: duration(0, 0, 0, 0, 0, 0, 200)> {
app::fetch("https://api.example.com/fast")
}
}When the outer timer fires before the inner body completes, the outer expiry takes precedence. The inner expiry is confined to the inner block’s scope. Each timeout block creates a scoped AbortController that chains to ctx.signal; when the timer fires the controller aborts, halting cooperating host functions. The RILL_R010 iteration ceiling (10,000 elements) applies inside timeout bodies regardless of timeout state.
Control Flow Summary
| Statement | Scope | Effect |
|---|---|---|
break | Loop | Exit loop with current $ |
$val -> break | Loop | Exit loop with value |
return | Block/Script | Exit block or script with current $ |
$val -> return | Block/Script | Exit block or script with value |
pass | Any | Returns current $ unchanged |
pass { body } | Pipe stage | Run body for side effects; pipe value unchanged; halts propagate |
pass<on_error: #IGNORE> { body } | Pipe stage | Run body; suppresses catchable halts in body; pipe value unchanged |
assert cond | Any | Halt if condition false, pass through on success |
assert cond "msg" | Any | Halt with custom message if condition false |
error "msg" | Any | Always halt with error message |
$val -> error | Any | Always halt with piped error message (must be string) |
guard { body } | Any | Replace halt with invalid value |
retry<limit: N> { body } | Any | Retry up to N times on caught halt |
timeout<total: duration> { body } | Block | Abort body on wall-time expiry; catchable halt #RILL_R082 (use guard to recover) |
timeout<idle: duration> { body } | Block | Abort body on inactivity; catchable halt #RILL_R083 (use guard to recover) |
Patterns
Guard Clauses
Exit early on invalid conditions (assumes host provides error()):
use<ext:app> => $app
|data| {
$data -> .empty ? $app.error("Empty input")
$data -> :?list ? $ ! $app.error("Expected list")
$data -> seq({ $ * 2 })
} => $processRetry with Limit
use<ext:app> => $app
do<limit: 3> {
$app.prompt("Try operation")
} while (.contains("RETRY"))
.contains("SUCCESS") ? [0, "Done"] ! list[1, "Failed"]State Machine
"start" -> while ($ != "done") do {
($ == "start") ? "processing" ! ($ == "processing") ? "validating" ! ($ == "validating") ? "done" ! $
}
# Walks through states: start -> processing -> validating -> doneFind First Match
[1, 2, 3, 4, 5] -> seq({
($ > 3) ? ($ -> break)
$
})
# Returns 4 (first element > 3)Loop Syntax Migration
rill previously used @ as the loop operator with ^(limit: N) annotations. The current syntax uses while/do keywords with the do<limit: N> construct option.
| Old syntax | New syntax |
|---|---|
initial -> (cond) @ { body } | initial -> while (cond) do { body } |
initial -> @ { body } ? (cond) | initial -> do { body } while (cond) |
initial -> (cond) @ ^(limit: N) { body } | initial -> while (cond) do<limit: N> { body } |
initial -> @ ^(limit: N) { body } ? (cond) | initial -> do<limit: N> { body } while (cond) |
See CHANGELOG.md for the full change history.
See Also
- Variables: Scope rules and
$binding - Collections:
seq,fan,filter,fold,acciteration - Operators: Comparison and logical operators
- Error Handling:
#RILL_R082and#RILL_R083recovery patterns;guard,??, and atom inspection - Reference: Quick reference tables