14. Statements and control flow
The grammar of statements is in §4.3; this chapter defines their meaning. Declarations and assignments are covered in §12; this chapter covers control flow.
14.1 Blocks
A block is an indentation-delimited sequence of one or more statements (§3.4). Each block is a scope (§12.3). A block cannot be empty; pass is the no-op that gives one a body (§14.1.1). The bare-expression statements permitted are a call, and a call followed by or_return or an or_else clause when the callee's ok payload is void (§5.3.6); a bare variable, index or field expression is rejected as having no effect, and so is an or_return or or_else over any other payload type, which would drop a value.
14.1.1 pass
pass
pass is the statement that does nothing. It is the body to write wherever the grammar demands a statement and the program has nothing to do there: a match arm that deliberately ignores its case, an if branch kept for its comment, a loop body whose work is entirely in its head.
fn drain(r: Result(int, string)) -> int:
match r:
Ok(n): return n
Err(e): pass # this case is deliberately ignored
return 0
pass is contextual, not reserved (Appendix B): it is a statement only in statement position, so pass := 0 and pass = pass + 1 remain ordinary code and a variable named pass keeps working. It is not an expression and MUST NOT appear where a value is expected. A hand-written empty function called for its emptiness is not the idiom and is not needed.
14.2 if / elif / else
if C1: … elif C2: … else: …
Each condition MUST be bool. Conditions are tested in order; the first true condition's block runs and the rest are skipped; if none is true and an else is present, its block runs. elif is exactly sugar for an else block containing a single nested if. A value-producing if (in tail position) is defined in §13.5.
14.3 match
match E: <arm>+
match evaluates its subject E exactly once (*probed*) and runs the arm whose pattern matches. A pattern is a variant name (optionally binding its payload into 0–8 names local to the arm), a qualified pkg.Variant, or the wildcard _. A match MUST be exhaustive: every variant of the subject's type MUST be covered, or a _ arm MUST be present; a non-exhaustive match is rejected at compile time. Payload bindings are visible only within their arm. The statement form runs a block per arm; the value form (arms are blocks ending in a value expression, tail position) is defined in §13.5.
14.3.1 Nested patterns
An Ok, Err or Some arm MAY refine its single payload with one nested pattern instead of binding it: Err(net.Timeout), Err(Timeout), Err(TooBig(n)). The nested pattern names a variant of the payload's enum type and MAY bind that variant's own payload into 0–8 names. Nesting is one level deep and is permitted only on an Option/Result payload: inside a plain enum arm a nested pattern is rejected, and the payload MUST be matched in its own match.
Because the payload's enum type is already known at the pattern, the nested variant MAY be written unqualified even when its enum belongs to another package — Err(Timeout) and Err(net.Timeout) are the same pattern. It follows that a name that is a variant of the payload's enum is always a pattern, never a binding: Err(Timeout) does not bind a variable called Timeout. Where such a name is not a legal pattern (it carries a payload but was written bare, or the enclosing arm is a plain enum arm) the program is rejected; it is never silently read as a binding.
A side of the match (its Ok arms, its Err arms, its Some arms) is an ordered decision list: refined arms are tested in source order, then the unrefined arm for that side, if any. An unrefined arm therefore MUST come last among its side's arms — a refined arm written after one is dead and is rejected as a duplicate. Two refined arms naming the same variant are likewise rejected. A side is exhaustive when it has an unrefined arm, when its refined arms name every variant of the payload's enum, or when a _ arm is present.
14.3.2 Scalar subjects
A match whose subject is an int, char, or bool takes scalar arms: a literal (1:, 'a':, true:), an inclusive range (1..9:), a set (1 | 3 | 5:), a set mixing ranges (45 | 48..57:), or an int constant name (OP_ADD:, OP_ADD..OP_GE:). The arm elements must be the subject's own kind: an int subject takes int literals and int constants; a char subject takes char literals; a bool subject takes true/false. A range's two ends must be the same kind, and its start must not exceed its end. The subject is evaluated exactly once, as in §14.3.
_ is required for int and char subjects — the domain is unbounded, so exhaustiveness is unprovable — and a non-exhaustive scalar match is rejected at compile time, exactly like a non-exhaustive enum match. A bool subject is exhaustive when both true and false are covered; _ is optional there.
Duplicate or overlapping arms are an error: 1..9 plus 5 is a duplicate, because an earlier arm would be dead. match on string, bytes, or float is refused — nothing in the tree dispatches on them; if/elif is the form.
The statement and value forms are those of §14.3. Codegen: a match with 4+ arms emits a C switch (a jump table at -O3 when the values are dense); fewer arms emit a chain.
import "core:httpd"
fn serve(req: httpd.Request):
println("served")
fn refuse(code: int):
println("refused " + str(code))
fd := 0
match httpd.read_request(fd):
Ok(req): serve(req)
Err(httpd.TooLarge): refuse(431)
Err(httpd.Timeout): refuse(408)
Err(e): refuse(500)
14.4 Loops
Tycho has one loop keyword, for, in four shapes; break and continue are valid in every shape and are errors outside a loop.
Condition (while) form — for C: runs its body while the bool condition C holds.
Infinite form — for: runs its body until a break or a return. It is the condition form with a literal true and nothing about it is otherwise special.
Three-clause form — for init; cond; post: runs init once, then runs the body while the bool cond holds, running post after every iteration. init is a declaration or an assignment and post is an assignment to a variable; a variable that init declares is scoped to the loop, and post is evaluated in the loop's scope, not the body's, so it cannot read a body-local. All three clauses are required — this grammar has no empty-statement production to put in one — and for: is the only degenerate form. A continue runs the post clause: it jumps *to* post, not past it, so a loop that advances only in post still terminates.
Foreach form — for x in xs: iterates a collection: an array (binding each element) or a string (binding each byte as an int). The collection is evaluated exactly once before the loop.
There is no counting form, and range is not a name the language knows. for i in range(a, b, step): was removed on 2026-07-29; a for head that names range is refused with both replacements spelled out. Count sequentially with the three-clause form; count in parallel with parallel for i in 0..<N: (§22), which is the only context where 0..<N is legal — a sequential for i in 0..<N: is refused.
There is no zero-step check. A three-clause post is arbitrary code, so no equivalent check exists: for i := 0; i < n; i += 0: is an infinite loop and the implementation does not diagnose it, at compile time or at run time. 0..<N steps by 1 implicitly and so has no zero-step case at all.
Rationale: a single loop form states its own direction and amount in the source.
14.5 return
return with no operand returns from a void function. return e returns the value of e, checked against the function's return type. return a, b, … returns a tuple (2–8 elements). return if … / return match … returns the value of a tail-position if/match (§13.5). A returned value is produced directly in the caller's storage (§10.2).
14.6 or_return
v := e or_return evaluates e, which MUST be an Option or a Result. For a Result, Ok(x) binds v := x and execution continues, while Err(err) causes the enclosing function to return Err(err) immediately — the function MUST return a Result(_, E) with the same error type E. For an Option, Some(x) binds v := x and None causes the enclosing function to return None. The short-circuited Err/None payload is promoted into the caller's storage so it outlives the return. or_return binds tighter than any binary operator.
14.6.1 or_else
OrElse ::= "or_else" IDENT ":" Expr /* IDENT is a name, or `_` to bind nothing */
v := e or_else x: h is or_return with a chosen return value. e MUST be an Option or a Result. On Ok(v)/Some(v) the expression is the payload, of the payload's type, exactly as for or_return. On Err(err), err is bound to x, h is evaluated and the enclosing function returns h. It never falls through as a fallback value — that is result.unwrap_or.
hMUST have the enclosing function's return type, which need not be aResultor anOption:or_else _: falsein aboolfunction returnsfalse. A mismatch is a compile error that names both types.- On an
Optionthe binding MUST be_, sinceNonecarries no value. xis in scope inhonly._binds nothing. An unusedxis not an error, as for amatch-arm binding.or_elsein a function that returns nothing is a compile error; useor_returnor amatchthere.his a wholeExpr, so it runs to the end of the line, or to the),]or,that closes the surrounding bracket. A block after the:is a syntax error.
In every other respect or_else follows or_return: the operand binds tighter than any binary operator; the statement form f() or_else x: h is legal exactly when the ok payload is void; h is promoted into the caller's storage before the function's scopes are freed; and or_else MUST NOT appear in a parallel for body.
enum IoErr:
Denied
enum AppErr:
Storage(IoErr)
Listen(string, int)
Wal(IoErr)
fn read(path: string) -> Result(string, IoErr):
return Ok(path)
fn listen(h: string, p: int) -> Result(int, IoErr):
return Ok(p)
fn write_at(b: string) -> Result(void, IoErr):
return Ok()
fn load(path: string, h: string, p: int) -> Result(int, AppErr):
s := read(path) or_else e: Err(Storage(e)) # wrap the error
fd := listen(h, p) or_else _: Err(Listen(h, p)) # replace it
write_at(s) or_else e: Err(Wal(e)) # Result(void, E): statement
return Ok(len(s) + fd)
14.7 delete
delete m[k] removes the entry for key k from the map place m (which may be a bare variable, a field, or a nested place). Deleting an absent key is a no-op.
14.8 die, exit, and termination
die(msg) prints msg to standard error and terminates the program with status 1. exit(code) terminates with status code and prints nothing — that is the one for a program that has *answered* (a --help needs 0, which die cannot give). Both are typed void, so a non-void function that dies in one branch still type-checks, and both are modelled as diverging, so either is legal as the tail of a value if/match branch even though it produces no value (§29.12.1). die is the only user-callable *abort* (§29; there is no assert/panic). Other terminating conditions (division by zero, out-of-bounds access, and the like) are the defined runtime aborts of §30. Normal termination occurs when main returns, after which all program storage is reclaimed (§10.3).