Control Flow in Roc
Learn control flow in Roc — if-then-else expressions, when pattern matching, guards, recursion, and list functions that replace loops, with Docker-ready examples
Control flow is how a program decides what to do next. In imperative languages this usually means statements that mutate state — if blocks executed for their side effects and for/while loops that increment counters into mutable variables. Roc is a purely functional language, so it reaches the same goals from a different direction.
In Roc there are no statements that branch for effect; there are expressions that evaluate to values. An if in Roc is not a branch you execute — it is an expression that produces a result, which is why it always requires an else. There are no traditional loops either, because bindings are immutable and there is no counter to increment. Instead, recursion replaces loops, when pattern matching replaces switch/case, and higher-order list functions like List.map and List.walk express iteration declaratively.
This tutorial covers the tools Roc gives you for choosing between values and repeating work: if/then/else expressions, when ... is pattern matching with guards, recursion as iteration, and list functions. Because Roc has 100% type inference, the compiler checks that every branch of a decision produces the same type — a whole class of bugs simply cannot occur.
If-Then-Else as an Expression
In Roc, if is an expression that returns a value, so it must always have an else. There is no “if without else” — every branch must produce a value of the same type. An else if chain handles more than two cases.
Create a file named conditionals.roc:
app [main!] { cli: platform "https://github.com/roc-lang/basic-cli/releases/download/0.20.0/X73hGh05nNTkDHU06FHC0YfFaQB1pimX7gncRcao5mU.tar.br" }
import cli.Stdout
# An `else if` chain handles more than two cases
sign : I64 -> Str
sign = |n|
if n > 0 then
"positive"
else if n < 0 then
"negative"
else
"zero"
main! = |_args|
x = 7
# Used inline, an `if` expression chooses between two values
label = if Num.rem(x, 2) == 0 then "even" else "odd"
Stdout.line!("${Num.to_str(x)} is ${label}")?
# Both branches must produce the same type (here, an integer)
bigger = if x > 5 then x else 5
Stdout.line!("bigger value: ${Num.to_str(bigger)}")?
Stdout.line!("sign of -3: ${sign(-3)}")?
Stdout.line!("sign of 0: ${sign(0)}")
Because if is an expression, you can use it anywhere a value is expected — inside a binding like label, as a function argument, or as the body of a function. There is no separate ternary operator; if/then/else already fills that role. Notice the ? operator after the intermediate Stdout.line! calls: it unwraps a successful Result and short-circuits on failure, which is how effectful statements are sequenced. The final line has no ?, so its Result becomes the value of main!.
When Expressions and Pattern Matching
The when ... is expression matches a single value against a series of patterns — Roc’s equivalent of switch/case, but far more powerful. Patterns can combine alternatives with |, attach a guard with if, and destructure data like lists.
Create a file named pattern_matching.roc:
app [main!] { cli: platform "https://github.com/roc-lang/basic-cli/releases/download/0.20.0/X73hGh05nNTkDHU06FHC0YfFaQB1pimX7gncRcao5mU.tar.br" }
import cli.Stdout
# `|` combines alternatives; `_` is the catch-all pattern
day_type : U8 -> Str
day_type = |day|
when day is
6 | 7 -> "weekend"
_ -> "weekday"
# A guard (`if`) refines a pattern with a boolean condition
classify : I64 -> Str
classify = |n|
when n is
0 -> "zero"
_ if n < 0 -> "negative"
_ if n < 10 -> "small"
_ -> "large"
# Matching destructures a list into its head and the rest
first_word : List Str -> Str
first_word = |words|
when words is
[] -> "(empty)"
[first, ..] -> first
main! = |_args|
Stdout.line!(day_type(6))?
Stdout.line!(day_type(3))?
Stdout.line!(classify(-5))?
Stdout.line!(classify(0))?
Stdout.line!(classify(7))?
Stdout.line!(classify(42))?
Stdout.line!(first_word(["hello", "world"]))?
Stdout.line!(first_word([]))
Pattern matching is the heart of control flow in Roc. The pattern [first, ..] matches a non-empty list, binding first to the head and ignoring the rest with .., while [] handles the empty case. Guards are evaluated top to bottom, so order matters — put the most specific conditions first. Because Roc enforces exhaustive matching, the compiler will refuse to build if you forget a case, which is why each when ends with a catch-all _.
Recursion Instead of Loops
Roc has no for or while loops, because there are no mutable counters to update. Repetition is expressed with recursion: a function that calls itself with a smaller input until it reaches a base case that stops the recursion.
Create a file named recursion.roc:
app [main!] { cli: platform "https://github.com/roc-lang/basic-cli/releases/download/0.20.0/X73hGh05nNTkDHU06FHC0YfFaQB1pimX7gncRcao5mU.tar.br" }
import cli.Stdout
# Recursion replaces a counting loop: each call reduces n toward the base case
factorial : U64 -> U64
factorial = |n|
if n <= 1 then
1
else
n * factorial(n - 1)
# A "while-style" accumulation, expressed as recursion
sum_to : U64 -> U64
sum_to = |n|
if n == 0 then
0
else
n + sum_to(n - 1)
# Build a countdown string by recursing toward the base case
countdown : U64 -> Str
countdown = |n|
if n == 0 then
"Liftoff!"
else
"${Num.to_str(n)} ${countdown(n - 1)}"
main! = |_args|
Stdout.line!("5! = ${Num.to_str(factorial(5))}")?
Stdout.line!("sum 1..100 = ${Num.to_str(sum_to(100))}")?
Stdout.line!("countdown: ${countdown(5)}")
Each recursive function has a base case (n <= 1, n == 0) that terminates the recursion and a recursive case that moves the problem toward that base. factorial(5) multiplies 5 * 4 * 3 * 2 * 1, sum_to(100) adds every number from 100 down to 0, and countdown builds its result by prepending each number to the string produced by the next call.
List Functions Instead of Loops
For iterating over a collection, Roc favors higher-order list functions over hand-written recursion. List.map transforms each element, List.keep_if filters, and List.walk folds a list into a single accumulated value — none of them needs a loop counter.
Create a file named iteration.roc:
app [main!] { cli: platform "https://github.com/roc-lang/basic-cli/releases/download/0.20.0/X73hGh05nNTkDHU06FHC0YfFaQB1pimX7gncRcao5mU.tar.br" }
import cli.Stdout
main! = |_args|
numbers = [1, 2, 3, 4, 5, 6]
# `List.map` transforms every element — no loop counter needed
doubled = List.map(numbers, |x| x * 2)
Stdout.line!("doubled = ${Inspect.to_str(doubled)}")?
# `List.keep_if` keeps only the elements that satisfy a predicate
evens = List.keep_if(numbers, |x| Num.rem(x, 2) == 0)
Stdout.line!("evens = ${Inspect.to_str(evens)}")?
# `List.walk` folds the list into a single accumulated value
total = List.walk(numbers, 0, |acc, x| acc + x)
Stdout.line!("sum = ${Num.to_str(total)}")
List.map(numbers, |x| x * 2) reads as “produce a new list where each x becomes x * 2.” List.keep_if takes a predicate and returns only the elements for which it is Bool.true. List.walk is the fold: it starts from an initial state (0), then combines the running accumulator acc with each element x. These functions replace the explicit loops of imperative languages while keeping every value immutable. Inspect.to_str renders a whole list as a string for printing.
Running with Docker
Run each example using the official Roc nightly image — no local installation required.
| |
Note: On the first run, Roc will download the
basic-cliplatform specified in each source file. This may take a few seconds.
Expected Output
Running conditionals.roc:
7 is odd
bigger value: 7
sign of -3: negative
sign of 0: zero
Running pattern_matching.roc:
weekend
weekday
negative
zero
small
large
hello
(empty)
Running recursion.roc:
5! = 120
sum 1..100 = 5050
countdown: 5 4 3 2 1 Liftoff!
Running iteration.roc:
doubled = [2, 4, 6, 8, 10, 12]
evens = [2, 4, 6]
sum = 21
Key Concepts
ifis an expression, not a statement — it always returns a value, so it must include boththenandelse, and both branches must have the same type.when ... isis Roc’s pattern match — it replacesswitch/case, supports|to combine alternatives, and refines branches withifguards.- Matching is exhaustive — the compiler refuses to build unless every possible case is handled, so a forgotten branch becomes a compile error rather than a runtime surprise.
- Patterns destructure data —
[first, ..]splits a list into its head and the rest, while[]matches the empty list. - Recursion replaces loops — because bindings are immutable, repetition is expressed by a function calling itself, with a base case to terminate and a recursive case that moves toward it.
- List functions replace explicit iteration —
List.map,List.keep_if, andList.walktransform, filter, and fold collections declaratively, without mutable counters. - The type system enforces consistency — Roc infers the type of every branch and requires them to agree, catching mismatches before the program ever runs.
- The
?operator sequences effects — it unwraps a successfulResultand short-circuits on failure, which is how multipleStdout.line!calls run in order.
Running Today
All examples can be run using Docker:
docker pull roclang/nightly-ubuntu-2204:latest
Comments
Loading comments...
Leave a Comment