Skip to content

Control flow & match

Control flow is conventional where it can be and expressive where it counts. No parentheses around conditions; braces are mandatory.

if x < 0 {
return 0 - x
}
if a > b {
return a
} else if b > c {
return b
} else {
return c
}
while i <= n {
total += i
i += 1
}
for i in 0..n { // range
buffer[i] = 0
}
for item in collection { // any iterable
process(item)
}
for event in ch { // a channel, until it closes
handle(event)
}

for..in also destructures tuples — and a Dictionary<K, V>, whose KeyValuePair<K, V> elements unpack into the key and value (each typed, so key.Length resolves):

for (key, value) in scores { // scores: Dictionary<string, int>
log("{key}: {value}")
}

match is where choice, ref choice, and enum pay off. Each arm names a variant with .case, optionally binding its payload.

choice ConnState {
disconnected
connecting
connected(sessionId: int)
failed(reason: string)
}
func describe(s: ConnState) -> string {
match s {
.disconnected { return "disconnected" }
.connecting { return "connecting" }
.connected(sid) { return "connected:{sid}" }
.failed(reason) { return "failed:{reason}" }
}
}

Multi-payload cases bind positionally (.message(lvl, txt)), and you can ignore a payload with _. There’s also a single-binding case view: bind the whole case to one name and reach its payloads by field — .pair(p) then p.a, p.b — which composes with positional binding so you pick whichever reads better.

For a single-payload case the case view is transparent: the bound name is the payload value, and name.field still resolves to the same thing — so .connected(sid) lets you use sid as the int directly. This holds for value choice and ref choice alike:

ref choice Json {
jbool(value: bool)
jarr(items: List<Json>)
}
match j {
.jbool(b) { return b ? "true" : "false" } // b IS the bool
.jarr(items) { return "[{items.Count}]" } // items IS the List
}

It’s pure sugar — .jbool(b) is exactly .jbool(c) reaching c.value, both lowering to the same payload load. (A multi-payload case has no single value to unwrap to, so it keeps the named view: .add(n) then n.left / n.right.)

ref choice matches the same way (it uses an isinst type check under the hood):

ref choice Expr {
literal(value: int)
add(left: Expr, right: Expr)
neg(inner: Expr)
}
func eval(e: Expr) -> int {
match e {
.literal(v) { return v }
.add(l, r) { return eval(l) + eval(r) }
.neg(inner) { return 0 - eval(inner) }
}
}

The compiler warns when a match over a choice/ref choice/enum misses a variant. Combined with the every-path-must-return rule, an exhaustive match where every arm returns is a complete function body — no trailing return needed. Add default { } as the catch-all when you want it.

match also dispatches on int, string, and bool literals:

match status {
200 { return "ok" }
404 { return "not found" }
default { return "unknown" }
}

(Literal universes are open, so default is how you stay total — exhaustiveness checking doesn’t apply here.)

A match can appear anywhere an expression can — a let, a return, an interpolation, an expression-bodied function. Every arm must produce the same type.

let label = match status {
200 { "ok" }
404 { "not found" }
default { "other" }
}
func describe(r: Result<int, string>) -> string = match r {
.ok(v) { "value={v}" }
.err(e) { "err:{e}" }
}

Browse match-heavy examples →

defer { ... } schedules cleanup that runs when the scope exits (it lowers to try/finally). Multiple defers run in LIFO order.

let handle = openFile(path)
defer { handle.Close() } // runs on the way out, however the scope exits

Standard return. The thing to remember is the definite-return rule: every path of a non-void function must return, and the analysis understands exhaustive match and if/else where both branches return, plus while true { ... } with no break. Correct exhaustive code needs no filler trailing return.