Beginner

Control Flow in Haskell

Learn control flow in Haskell — if-expressions, guards, case expressions, pattern matching, recursion, and list comprehensions with Docker-ready examples

Control flow is how a program decides what to do next. In most languages this means statements that mutate state — if blocks that run for their side effects, for and while loops that count and accumulate into variables. Haskell is purely functional, so it approaches the same problems from a completely different angle.

In Haskell there are no statements, only expressions that evaluate to values. An if in Haskell is not a branch you “execute” — it is an expression that produces a result, which is why it always needs both a then and an else. There are no traditional loops either: because variables are immutable, you cannot increment a counter. Instead, recursion replaces loops, pattern matching and guards replace most conditionals, and list comprehensions and higher-order functions express iteration declaratively.

This tutorial covers the tools Haskell gives you for choosing between values and repeating work: if/then/else expressions, guards, case expressions, pattern matching, recursion as iteration, and list comprehensions. Because Haskell is statically typed with inference, the compiler checks that every branch of a decision produces the same type — a class of bugs simply cannot occur.

If-Then-Else as an Expression

In Haskell, 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 result of the same type.

Create a file named conditionals.hs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
main :: IO ()
main = do
    let x = 7

    -- if/then/else is an expression that yields a value
    let label = if even x then "even" else "odd"
    putStrLn ("x is " ++ label)

    -- Both branches must have the same type (here, Int)
    let bigger = if x > 5 then x else 5
    putStrLn ("bigger value: " ++ show bigger)

    -- Nested if expressions handle multiple cases
    let sign n = if n > 0 then "positive"
                 else if n < 0 then "negative"
                 else "zero"
    putStrLn ("sign of -3: " ++ sign (-3))
    putStrLn ("sign of 0:  " ++ sign 0)

Because if is an expression, you can use it anywhere a value is expected — inside a let, as a function argument, or as the body of a function. There is no need for a separate ternary operator; if/then/else already fills that role.

Guards: Choosing Between Conditions

When a function needs to pick a result based on several boolean conditions, guards are cleaner than nested if expressions. Each guard is a condition introduced by |; the first one that is True wins. The catch-all otherwise is simply defined as True.

Create a file named guards.hs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
-- Guards select a branch based on boolean conditions
classify :: Int -> String
classify n
    | n < 0     = "negative"
    | n == 0    = "zero"
    | n < 10    = "small"
    | otherwise = "large"

-- Pattern matching on specific values, with a wildcard fallback
describe :: Int -> String
describe 0 = "none"
describe 1 = "one"
describe 2 = "a couple"
describe _ = "many"

-- Guards work well for ranges, like assigning a letter grade
grade :: Int -> Char
grade score
    | score >= 90 = 'A'
    | score >= 80 = 'B'
    | score >= 70 = 'C'
    | otherwise   = 'F'

main :: IO ()
main = do
    mapM_ (putStrLn . classify) [-5, 0, 7, 42]
    mapM_ (putStrLn . describe) [0, 1, 2, 5]
    -- A String is a list of Char, so [grade ...] is a String
    putStrLn [grade 95, grade 83, grade 71, grade 50]

Guards are evaluated top to bottom, so order matters — put the most specific conditions first. Note how describe uses pattern matching (matching against the literal values 0, 1, 2) with _ as a wildcard for everything else. This is often the most idiomatic replacement for a switch statement.

Case Expressions and Pattern Matching

The case expression matches a single value against a series of patterns. It is Haskell’s equivalent of switch, but far more powerful: patterns can destructure data, and you can attach guards to each branch.

Create a file named case_match.hs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
-- A case expression matches a value against patterns
dayType :: Int -> String
dayType day = case day of
    6 -> "weekend"
    7 -> "weekend"
    _ -> "weekday"

-- Pattern matching destructures lists: (x:xs) splits head from tail
firstWord :: [String] -> String
firstWord []    = "(empty)"
firstWord (w:_) = w

-- case branches can carry guards for range checks
temperature :: Int -> String
temperature t = case () of
    _ | t < 0     -> "freezing"
      | t < 20    -> "cool"
      | t < 30    -> "warm"
      | otherwise -> "hot"

main :: IO ()
main = do
    putStrLn (dayType 6)
    putStrLn (dayType 3)
    putStrLn (firstWord ["hello", "world"])
    putStrLn (firstWord [])
    putStrLn (temperature 25)

Pattern matching is the heart of control flow in Haskell. The pattern (w:_) matches a non-empty list, binding w to the first element and ignoring the rest. The empty-list pattern [] handles the other case. Because the compiler can warn about missing patterns, you are nudged toward handling every possibility — a real safety benefit over forgetting a default case elsewhere.

Recursion and List Comprehensions Instead of Loops

Haskell has no for or while loops, because there are no mutable counters to update. Repetition is expressed with recursion, and iteration over collections is expressed with list comprehensions or higher-order functions like map and mapM_.

Create a file named iteration.hs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
-- Recursion replaces a counting loop: build a list down to zero
countdown :: Int -> [Int]
countdown 0 = [0]
countdown n = n : countdown (n - 1)

-- A "while-style" accumulation, expressed as recursion
sumTo :: Int -> Int
sumTo 0 = 0
sumTo n = n + sumTo (n - 1)

-- A list comprehension iterates and filters declaratively
evens :: [Int] -> [Int]
evens xs = [x | x <- xs, even x]

main :: IO ()
main = do
    print (countdown 5)
    print (sumTo 100)
    print (evens [1..10])
    -- mapM_ acts like a for-each loop that performs IO
    mapM_ (\i -> putStrLn ("line " ++ show i)) [1..3]

Each recursive function has a base case (countdown 0, sumTo 0) that stops the recursion and a recursive case that reduces the problem toward that base. The list comprehension [x | x <- xs, even x] reads as “collect each x drawn from xs where x is even” — the filtering condition replaces an if inside a loop body. For side-effecting iteration, mapM_ runs an IO action once per element, much like a for-each loop.

Running with Docker

Run each example using the official Haskell image — no local installation required.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Pull the official image
docker pull haskell:9.6

# Run the if/then/else example
docker run --rm -v $(pwd):/app -w /app haskell:9.6 runghc conditionals.hs

# Run the guards example
docker run --rm -v $(pwd):/app -w /app haskell:9.6 runghc guards.hs

# Run the case / pattern matching example
docker run --rm -v $(pwd):/app -w /app haskell:9.6 runghc case_match.hs

# Run the recursion / list comprehension example
docker run --rm -v $(pwd):/app -w /app haskell:9.6 runghc iteration.hs

Expected Output

Running conditionals.hs:

x is odd
bigger value: 7
sign of -3: negative
sign of 0:  zero

Running guards.hs:

negative
zero
small
large
none
one
a couple
many
ABCF

Running case_match.hs:

weekend
weekday
hello
(empty)
warm

Running iteration.hs:

[5,4,3,2,1,0]
5050
[2,4,6,8,10]
line 1
line 2
line 3

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.
  • Guards (|) select a result based on boolean conditions, evaluated top to bottom; otherwise is the catch-all and is just True.
  • Pattern matching on literals, [], and (x:xs) is the idiomatic replacement for switch and is checked by the compiler for completeness.
  • case expressions match a value against patterns and can attach guards to each branch for range checks.
  • Recursion replaces loops — every recursive function needs a base case to terminate and a recursive case that moves toward it.
  • List comprehensions like [x | x <- xs, even x] express iteration and filtering declaratively, without mutable counters.
  • Higher-order functions (map, filter, mapM_) iterate over collections; mapM_ is the functional equivalent of a for-each loop for IO actions.
  • The type system enforces consistency — every branch of a decision must produce the same type, catching a whole class of mistakes at compile time.

Running Today

All examples can be run using Docker:

docker pull haskell:9.6
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining