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.

SyntaxDescription
cond ? then ! elseConditional (if-else)
$val -> ? then ! elsePiped 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 -> breakExit loop
return / $val -> returnExit block
assert cond / assert cond "msg"Validate condition, halt on failure
error "msg" / $val -> errorHalt 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 condition

Piped 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 condition

Optional Else

The else branch (! ...) is optional:

true ? "executed"                   # only runs if true
false ? "skipped"                   # returns empty string

Else-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: 6

Iteration Limit

Use do<limit: N> as the construct option to set maximum iterations (default: 10,000):

0 -> while ($ < 10) do<limit: 100> { $ + 1 }
# Result: 10

Exceeding 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, $.iter

This 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 RETRY

Iteration Limit

0 -> do<limit: 100> { $ + 1 } while ($ < 10)
# Result: 10

Break

Exit a loop early. Returns the value piped to break, or current $ if bare.

Syntax

break                    # exit with current $
$value -> break          # exit with value

In 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 4

Break 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 fan

Return

Exit a block early. Returns the value piped to return, or current $ if bare.

Syntax

return                   # exit with current $
$value -> return         # exit with value

In 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 condition

Basic 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 failed

Custom 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 failed

In 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 positive

Pipe 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:

ConditionError CodeMessage
Condition is falseRUNTIME_ASSERTION_FAILEDCustom message or “Assertion failed”
Condition is not booleanRUNTIME_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 failed

Validation Patterns

Guard clauses at function start:

|data| {
  assert $data:?list "Expected list"
  assert !$data.empty "List cannot be empty"
  $data -> seq({ $ * 2 })
} => $process
true

Multi-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 form

Basic Usage

Use error with a string literal to halt with a message:

error "Something went wrong"
# Halts execution with: Something went wrong

The 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 failed

The 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: 404

Piping non-string values throws a type error:

42 -> error                      # Error: error requires string, got number

String Interpolation

Use interpolation for dynamic error messages:

404 => $code
error "Unexpected status: {$code}"
# Halts with: Unexpected status: 404
3 => $step
"timeout" => $reason
error "Failed at step {$step}: {$reason}"
# Halts with: Failed at step 3: timeout

Conditional 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 empty

In 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
true

Error Behavior

Error throws RuntimeError with code RUNTIME_ERROR_RAISED:

PatternHalts WithMessage Source
error "msg"RUNTIME_ERROR_RAISEDString literal
$val -> errorRUNTIME_ERROR_RAISEDPiped value (must be string)
error ""RUNTIME_ERROR_RAISEDEmpty message
error 123Parse errorPARSE_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

StatementConditionBehavior
assert cond "msg"Validates conditionHalts if condition is false, passes through if true
error "msg"NoneAlways 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 2

Pass

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() ! pass

Pass Behavior

PatternReturnsContext
cond ? pass ! alt$ if true, alt if falseConditional branch
cond ? alt ! passalt if true, $ if falseConditional branch
[key: pass]Dict with $ as valueDict 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.

FormSuppresses 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 5
range(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 valid

Using 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") } => $result

retry 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

PatternUse 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:

FormBoundsExpiry 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_500ms

Recovery 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" ! $result

Use ?? 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

StatementScopeEffect
breakLoopExit loop with current $
$val -> breakLoopExit loop with value
returnBlock/ScriptExit block or script with current $
$val -> returnBlock/ScriptExit block or script with value
passAnyReturns current $ unchanged
pass { body }Pipe stageRun body for side effects; pipe value unchanged; halts propagate
pass<on_error: #IGNORE> { body }Pipe stageRun body; suppresses catchable halts in body; pipe value unchanged
assert condAnyHalt if condition false, pass through on success
assert cond "msg"AnyHalt with custom message if condition false
error "msg"AnyAlways halt with error message
$val -> errorAnyAlways halt with piped error message (must be string)
guard { body }AnyReplace halt with invalid value
retry<limit: N> { body }AnyRetry up to N times on caught halt
timeout<total: duration> { body }BlockAbort body on wall-time expiry; catchable halt #RILL_R082 (use guard to recover)
timeout<idle: duration> { body }BlockAbort 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 })
} => $process

Retry 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 -> done

Find 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 syntaxNew 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