Beginner

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Pull the official Roc nightly image
docker pull roclang/nightly-ubuntu-2204:latest

# Run the if/then/else example
docker run --rm -v $(pwd):/app -w /app roclang/nightly-ubuntu-2204:latest roc conditionals.roc

# Run the when / pattern matching example
docker run --rm -v $(pwd):/app -w /app roclang/nightly-ubuntu-2204:latest roc pattern_matching.roc

# Run the recursion example
docker run --rm -v $(pwd):/app -w /app roclang/nightly-ubuntu-2204:latest roc recursion.roc

# Run the list functions example
docker run --rm -v $(pwd):/app -w /app roclang/nightly-ubuntu-2204:latest roc iteration.roc

Note: On the first run, Roc will download the basic-cli platform 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

  • if is an expression, not a statement — it always returns a value, so it must include both then and else, and both branches must have the same type.
  • when ... is is Roc’s pattern match — it replaces switch/case, supports | to combine alternatives, and refines branches with if guards.
  • 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 iterationList.map, List.keep_if, and List.walk transform, 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 successful Result and short-circuits on failure, which is how multiple Stdout.line! calls run in order.

Running Today

All examples can be run using Docker:

docker pull roclang/nightly-ubuntu-2204:latest
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining