Elixir gives you several tools to make decisions and repeat work. In this article we’ll explore:
- Choosing between
caseand multi‑clause functions - Using the
withspecial form to chain operations that can fail - Expressing loops with recursion
- Turning ordinary recursion into tail‑recursive form for unlimited iteration
Each section introduces the idea, explains why it matters, and shows fresh, real‑world examples that differ from the classic “TODO list” or “user registration” snippets you may have seen elsewhere.
Why Control Flow Matters in a Functional Language
In imperative languages, if, while, and for are the building blocks of every program. In Elixir, the same goals are reached with pattern matching, guards, and recursion. Understanding the idioms that fit the language’s immutable, concurrent model lets you write clearer, more reliable code and avoid pitfalls like unnecessary nesting or stack overflows.
1. Pattern Matching with case vs. Multi‑Clause Functions
Elixir lets you dispatch to different pieces of code based on the shape of a value. You can do this:
- Directly with a
caseexpression, or - By defining several clauses of a function where each clause matches a distinct pattern.
Both approaches are equivalent under the hood, but they feel different. Use case when you want the decision to stay local to a single function body; pick multi‑clause functions when the branching logic cleanly belongs to the function’s public interface.
Example Domain: Warehouse Item Look‑up
Suppose we receive a map that may contain three required keys: "sku", "price", and "quantity". We want to turn that raw map into a struct %Item{} or return an error tuple.
Using case (local branching):
defmodule Inventory do
defstruct [:sku, :price, :quantity]
def normalize(map) do
case fetch_sku(map) do
{:error, reason} -> {:error, reason}
{:ok, sku} ->
case fetch_price(map) do
{:error, reason} -> {:error, reason}
{:ok, price} ->
case fetch_quantity(map) do
{:error, reason} -> {:error, reason}
{:ok, quantity} ->
{:ok, %Inventory{sku: sku, price: price, quantity: quantity}}
end
end
end
end
# Helpers (pattern matching on a map)
defp fetch_sku(%{"sku" => sku}), do: {:ok, sku}
defp fetch_sku(_), do: {:error, "sku missing"}
defp fetch_price(%{"price" => price}), do: {:ok, price}
defp fetch_price(_), do: {:error, "price missing"}
defp fetch_quantity(%{"quantity" => qty}), do: {:ok, qty}
defp fetch_quantity(_), do: {:error, "quantity missing"}
end
While correct, the nested case blocks become cumbersome quickly.
Using multi‑clause functions (declarative branching):
defmodule Inventory do
defstruct [:sku, :price, :quantity]
# Public API – a single clause that delegates to helpers
def normalize(map), do: normalize(map, %Inventory{})
# Clause that succeeds when all fields are present
defp normalize(%{"sku" => sku, "price" => price, "quantity" => qty}, _acc) do
{:ok, %Inventory{sku: sku, price: price, quantity: qty}}
end
# Fallback clause – matches anything that didn’t satisfy the first
defp normalize(_, _), do: {:error, "missing required fields"}
end
Here the function heads do the heavy lifting, letting pattern matching express the entire control flow without a single case.
When to Prefer One Over the Other
caseshines when you need a quick, one‑off match inside a larger function body, or when you have to match on the result of an expression that isn’t a function argument.- Multi‑clause functions are ideal when the decision belongs to the function’s public contract and you want each branch to be a clearly separated clause.
2. Chaining Dependent Operations with with
The with special form is Elixir’s answer to “run a series of steps, and bail out on the first failure.” It reads like a series of pattern‑matches, each one feeding its bound variables into the next step.
Scenario: Building a Shipping Label
Imagine an API that gathers three pieces of data from distinct services in order to produce a printable shipping label:
fetch_address/1– extracts a verified address from a customer map.lookup_rate/2– calculates the shipping rate based on address and weight.format_label/3– puts everything together into a printable string.
Each function returns {:ok, value} on success or {:error, reason} on failure.
defmodule Shipping do
# Simulated helpers
defp fetch_address(%{"address" => address}) when is_binary(address), do: {:ok, address}
defp fetch_address(_), do: {:error, "address missing"}
defp lookup_rate(address, weight) when is_number(weight) and weight > 0 do
# pretend we call an external service
{:ok, "Rate for #{address} (#{weight}kg): $#{weight * 1.5}"}
end
defp lookup_rate(_, _), do: {:error, "invalid weight"}
defp format_label(address, rate, order_id) do
{:ok, """
---- Shipping Label ----
Order: #{order_id}
To: #{address}
Rate: #{rate}
-------------------------
"""}
end
# The with pipeline
def generate_label(order) do
with {:ok, address} <- fetch_address(order),
{:ok, rate} <- lookup_rate(address, order["weight"]),
{:ok, label} <- format_label(address, rate, order["id"]) do
{:ok, label}
else
{:error, reason} -> {:error, reason}
end
end
end
Notice how the with block:
- Attempts the first match (
fetch_address/1). - If successful, binds
addressand proceeds to the next line. - Stops at the first failure, returning the error tuple unchanged.
Because with automatically returns the first non‑matching result, we can omit the explicit else clause in many cases. Adding else is handy when you need to transform the error, log it, or provide a fallback.
Why Not Just Use Nested case?
Nested case statements quickly become a vertical “pyramid of doom.” with flattens the structure, making the intent easier to read, especially when you have three, four, or more steps.
3. Looping without Loops: Recursion Basics
Elixir does not have while or for loops that mutate a variable in place. Instead, you describe the problem recursively: a function calls itself with a *smaller* or *simpler* argument until it reaches a base case.
Printing the First n Fibonacci Numbers
We’ll build a small module that prints the first n Fibonacci numbers using recursion. The pattern is the same as “print numbers from 1 to n” – you handle the base case (when n is 0) and otherwise call yourself with n‑1.
defmodule FibPrinter do
# Base case – nothing left to print
def print(0), do: :ok
# Recursive case – compute the next number, print the rest, then the current one
def print(n) when n > 0 do
print(n - 1)
IO.puts(fib(n))
end
# Simple memo‑less fibonacci (inefficient but illustrative)
defp fib(0), do: 0
defp fib(1), do: 1
defp fib(k), do: fib(k - 1) + fib(k - 2)
end
Calling FibPrinter.print(5) yields:
0
1
1
2
3
5
We used two distinct recursion patterns in the same module: one to iterate over n and another (the naïve fib/1) that recursively calls itself to compute a value. The first recursion is tail‑recursive because the last operation in the function body is the recursive call (print(n‑1)), while the second is not – the addition after the two recursive calls prevents tail optimisation.
Summing a List the Classic Way
defmodule Math do
def sum([]), do: 0
def sum([head | tail]), do: head + sum(tail)
end
This version is concise and expresses the mathematical definition of a sum. However, each recursive step must keep the intermediate head + … on the stack, which can cause a stack overflow for very large lists.
4. Tail‑Recursive Patterns for Unlimited Iteration
When the recursive call is the very last expression in a function, the BEAM virtual machine can replace the call with a “jump”, avoiding any extra stack frame. This is called tail‑call optimization (TCO). In practice, you rewrite the “non‑tail” version to carry an accumulator that stores the intermediate result.
Tail‑Recursive Summation
defmodule Math do
# Public entry point – initiates the accumulator at 0
def sum(list), do: do_sum(list, 0)
# Base case – empty list returns the accumulated total
defp do_sum([], acc), do: acc
# Recursive case – add the head to the accumulator and tail‑call
defp do_sum([head | tail], acc), do: do_sum(tail, acc + head)
end
From the caller’s perspective, Math.sum/1 behaves exactly like the non‑tail version, but it can safely handle lists with millions of elements without blowing the stack.
Tail‑Recursive Generation of a Range
Elixir already ships Enum.to_list/1, but building a range manually illustrates the pattern.
defmodule RangeBuilder do
def build(to) when is_integer(to) and to >= 0, do: do_build(to, [])
# When the counter reaches 0, reverse the collected list
defp do_build(0, acc), do: Enum.reverse(acc)
# Add the current number to the accumulator and continue
defp do_build(n, acc) when n > 0 do
do_build(n - 1, [n | acc])
end
end
Calling RangeBuilder.build(5) returns [1, 2, 3, 4, 5]. The accumulator acc never grows the stack; each recursive step simply reuses the current stack frame.
Infinite Loops with Tail Recursion
Because tail recursion never consumes more stack space, you can express an infinite loop safely. A classic example is a simple “keep listening for messages” process.
defmodule EchoServer do
def start do
loop()
end
defp loop do
receive do
{:echo, msg} ->
IO.puts("Echo: #{msg}")
loop() # tail‑recursive call → never grows the stack
:stop ->
:ok
end
end
end
The loop/0 function repeatedly re-invokes itself after handling a message. Since the recursive call is the last action, the BEAM treats it as a jump, allowing the server to run indefinitely without memory leaks.
When to Choose Tail Recursion
- Large data sets: Summing a list of 10 million numbers.
- Potentially infinite processes: Server loops, periodic tasks.
- Performance‑critical sections: Eliminating extra stack frames can yield measurable speedups.
Non‑tail recursion is still valuable when the algorithm naturally fits the definition (e.g., depth‑first tree traversals) or when readability outweighs the need for maximal efficiency.
5. Common Patterns & Pitfalls
Pattern: Guard‑Rich Multi‑Clause Functions
Guard clauses let you further refine matches without resorting to case. Example:
defmodule Temperature do
def classify(temp) when temp < 0, do: :freezing
def classify(temp) when temp < 15, do: :cold
def classify(temp) when temp < 25, do: :warm
def classify(_), do: :hot
end
Pitfall: Forgetting the Default Clause
When you rely solely on pattern matching, any input that doesn’t fit a clause raises FunctionClauseError. Always provide a fallback clause (often using the underscore _) or an else clause in with to keep the system robust.
Pitfall: Non‑Tail Recursion in Hot Paths
If a function is called thousands of times per second with large inputs, the extra stack frames from non‑tail recursion can become a bottleneck. Profile with :timer.tc/1 or the mix run --trace tool to decide if a tail‑recursive rewrite is warranted.
Pattern: Using with for Validation Pipelines
When you need to verify a series of constraints (e.g., input shape, business rules, external service responses), chain them with with. Each step returns {:ok, data} or {:error, reason}, making the pipeline short‑circuit on the first failure.
6. Summary – The Elixir Way to Branch and Loop
- Case vs. multi‑clause functions: Choose
casefor local, ad‑hoc branching; pick multi‑clause functions when the pattern belongs to the function’s signature. - With: A clean way to chain operations that return
{:ok, …}or{:error, …}. It flattens nestedcasestatements and returns the first failing result automatically. - Recursion is the backbone of iteration in Elixir. Write a base case and a recursive step that moves the problem closer to that base case.
- Tail recursion enables “loop‑like” performance: keep state in an accumulator and make the recursive call the last operation.
- Infinite loops are just tail‑recursive functions that keep calling themselves after handling each message.
By mastering these constructs, you’ll write Elixir code that feels natural, stays performant, and leverages the language’s strengths—immutable data, pattern matching, and lightweight processes.