Control Flow
Elixir provides several constructs for control flow, all based on pattern matching and expressions that return values.
if and unless
Basic if
if true do
"This will execute"
end
# => "This will execute"
if false do
"This won't execute"
end
# => nil
if/else
age = 18
if age >= 18 do
"Adult"
else
"Minor"
end
# => "Adult"
# One-liner syntax
if age >= 18, do: "Adult", else: "Minor"
unless
unless is the opposite of if - executes when condition is false.
unless is_nil(value) do
"Value exists"
else
"Value is nil"
end
# Equivalent to:
if is_nil(value) do
"Value is nil"
else
"Value exists"
end
Note: Avoid unless with else - it's confusing. Use if instead.
case
Pattern match against multiple clauses. First match wins.
Basic case
case {1, 2, 3} do
{1, x, 3} ->
"Matched! x = #{x}"
{4, 5, 6} ->
"Won't match"
_ ->
"Default case"
end
# => "Matched! x = 2"
Multiple Patterns
result = {:ok, 42}
case result do
{:ok, value} ->
"Success: #{value}"
{:error, reason} ->
"Error: #{reason}"
_ ->
"Unknown"
end
# => "Success: 42"
With Guards
Guards add additional conditions to patterns.
case {1, 2, 3} do
{x, y, z} when x + y == z ->
"x + y equals z"
{x, y, z} when x * 2 == y ->
"x * 2 equals y"
_ ->
"No match"
end
# => "No match"
Practical Example: HTTP Response
case HTTPClient.get(url) do
{:ok, %{status: 200, body: body}} ->
{:ok, parse_body(body)}
{:ok, %{status: 404}} ->
{:error, :not_found}
{:ok, %{status: status}} ->
{:error, {:http_error, status}}
{:error, reason} ->
{:error, {:network_error, reason}}
end
cond
Check multiple conditions (like else-if chains).
age = 25
cond do
age < 13 ->
"Child"
age < 20 ->
"Teenager"
age < 65 ->
"Adult"
true ->
"Senior"
end
# => "Adult"
Important: At least one condition must be true, or you get an error. Use true -> as a catch-all.
Practical Example: Grade Calculation
score = 85
grade = cond do
score >= 90 -> "A"
score >= 80 -> "B"
score >= 70 -> "C"
score >= 60 -> "D"
true -> "F"
end
# => "B"
with
Chain operations that might fail, with early return on error.
Basic with
with {:ok, file} <- File.read("config.json"),
{:ok, data} <- Jason.decode(file),
{:ok, validated} <- validate(data) do
{:ok, validated}
end
If any step returns something that doesn't match the pattern, that value is returned immediately.
with/else
Handle failures with specific patterns:
with {:ok, user} <- fetch_user(id),
{:ok, posts} <- fetch_posts(user.id),
{:ok, comments} <- fetch_comments(posts) do
{:ok, {user, posts, comments}}
else
{:error, :not_found} ->
{:error, "User not found"}
{:error, :database_error} ->
{:error, "Database connection failed"}
error ->
{:error, "Unknown error: #{inspect(error)}"}
end
Practical Example: User Registration
def register_user(params) do
with {:ok, validated} <- validate_params(params),
{:ok, hashed_password} <- hash_password(validated.password),
{:ok, user} <- insert_user(validated, hashed_password),
{:ok, _email} <- send_welcome_email(user) do
{:ok, user}
else
{:error, :invalid_email} ->
{:error, "Email format is invalid"}
{:error, :weak_password} ->
{:error, "Password too weak"}
{:error, :email_taken} ->
{:error, "Email already registered"}
error ->
Logger.error("Registration failed: #{inspect(error)}")
{:error, "Registration failed"}
end
end
Loops and Recursion
Elixir has no traditional loops - use recursion or Enum module.
Recursion Pattern
defmodule Counter do
def count_down(0), do: IO.puts("Done!")
def count_down(n) do
IO.puts(n)
count_down(n - 1)
end
end
Counter.count_down(5)
# 5
# 4
# 3
# 2
# 1
# Done!
List Processing with Recursion
defmodule ListSum do
def sum([]), do: 0
def sum([head | tail]), do: head + sum(tail)
end
ListSum.sum([1, 2, 3, 4, 5])
# => 15
Tail Recursion (Optimized)
Use accumulator for better performance:
defmodule ListSum do
def sum(list), do: sum(list, 0)
defp sum([], acc), do: acc
defp sum([head | tail], acc), do: sum(tail, acc + head)
end
ListSum.sum([1, 2, 3, 4, 5])
# => 15
Enum Module (Preferred for Loops)
Instead of writing recursion, use Enum:
each - Iterate
Enum.each([1, 2, 3], fn x ->
IO.puts(x)
end)
# 1
# 2
# 3
map - Transform
Enum.map([1, 2, 3], fn x -> x * 2 end)
# => [2, 4, 6]
# Shorthand
Enum.map([1, 2, 3], &(&1 * 2))
filter - Select
Enum.filter([1, 2, 3, 4, 5], fn x -> rem(x, 2) == 0 end)
# => [2, 4]
reduce - Accumulate
Enum.reduce([1, 2, 3, 4, 5], 0, fn x, acc -> x + acc end)
# => 15
# With & shorthand
Enum.reduce([1, 2, 3, 4, 5], 0, &+/2)
Chaining Operations
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|> Enum.filter(&(rem(&1, 2) == 0)) # Keep evens
|> Enum.map(&(&1 * &1)) # Square
|> Enum.take(3) # First 3
# => [4, 16, 36]
Common Enum Functions
# all? - Check if all match
Enum.all?([2, 4, 6], &(rem(&1, 2) == 0)) # => true
# any? - Check if any match
Enum.any?([1, 2, 3], &(&1 > 2)) # => true
# find - Get first match
Enum.find([1, 2, 3, 4], &(&1 > 2)) # => 3
# count
Enum.count([1, 2, 3]) # => 3
# member?
Enum.member?([1, 2, 3], 2) # => true
# at - Get element at index
Enum.at([1, 2, 3], 1) # => 2
# zip - Combine two lists
Enum.zip([1, 2, 3], [:a, :b, :c])
# => [{1, :a}, {2, :b}, {3, :c}]
Comprehensions
Syntactic sugar for mapping and filtering:
Basic for
for n <- [1, 2, 3, 4], do: n * 2
# => [2, 4, 6, 8]
With Filter
for n <- [1, 2, 3, 4, 5, 6], rem(n, 2) == 0, do: n
# => [2, 4, 6]
Multiple Generators
for x <- [1, 2], y <- [3, 4], do: {x, y}
# => [{1, 3}, {1, 4}, {2, 3}, {2, 4}]
Pattern Matching in Generator
users = [
%{name: "Alice", age: 30},
%{name: "Bob", age: 25},
%{name: "Charlie", age: 35}
]
for %{name: name, age: age} <- users, age > 26, do: name
# => ["Alice", "Charlie"]
Into Different Collections
# Into a map
for {k, v} <- [a: 1, b: 2, c: 3], into: %{}, do: {k, v * 2}
# => %{a: 2, b: 4, c: 6}
# Into a binary (string)
for c <- 'hello', into: "", do: <<c + 1>>
# => "ifmmp"
Practical Example: Nested Iteration
# Multiplication table
for x <- 1..10, y <- 1..10, do: {x, y, x * y}
# Filter coordinates
for x <- 1..5, y <- 1..5, x + y <= 6, do: {x, y}
Guards
Additional conditions in pattern matching:
Guard Clauses
defmodule Math do
def abs(n) when n < 0, do: -n
def abs(n) when n >= 0, do: n
end
Math.abs(-5) # => 5
Math.abs(5) # => 5
Multiple Guards
defmodule Classify do
def number_type(n) when is_integer(n) and n < 0, do: :negative_integer
def number_type(n) when is_integer(n) and n > 0, do: :positive_integer
def number_type(n) when n == 0, do: :zero
def number_type(n) when is_float(n), do: :float
end
Allowed in Guards
Only certain expressions are allowed in guards:
# Comparison operators: ==, !=, ===, !==, <, <=, >, >=
# Boolean operators: and, or, not
# Arithmetic: +, -, *, /
# Type checks: is_integer, is_float, is_atom, is_list, etc.
# Other: length, hd, tl, elem, etc.
Not allowed: Your own functions, most standard library functions
Practical Examples
FizzBuzz
defmodule FizzBuzz do
def run(n) do
1..n
|> Enum.map(&fizzbuzz/1)
|> Enum.each(&IO.puts/1)
end
defp fizzbuzz(n) when rem(n, 15) == 0, do: "FizzBuzz"
defp fizzbuzz(n) when rem(n, 3) == 0, do: "Fizz"
defp fizzbuzz(n) when rem(n, 5) == 0, do: "Buzz"
defp fizzbuzz(n), do: n
end
FizzBuzz.run(15)
Processing API Response
def process_response(response) do
case response do
{:ok, %{status: 200, body: body}} ->
case Jason.decode(body) do
{:ok, data} -> transform_data(data)
{:error, _} -> {:error, :invalid_json}
end
{:ok, %{status: status}} when status in 400..499 ->
{:error, :client_error}
{:ok, %{status: status}} when status in 500..599 ->
{:error, :server_error}
{:error, reason} ->
{:error, {:network, reason}}
end
end
# Better with `with`:
def process_response(response) do
with {:ok, %{status: 200, body: body}} <- response,
{:ok, data} <- Jason.decode(body) do
transform_data(data)
else
{:ok, %{status: status}} when status in 400..499 ->
{:error, :client_error}
{:ok, %{status: status}} when status in 500..599 ->
{:error, :server_error}
error ->
{:error, error}
end
end
Validation Pipeline
def validate_user(params) do
with :ok <- validate_email(params[:email]),
:ok <- validate_password(params[:password]),
:ok <- validate_age(params[:age]) do
{:ok, params}
else
{:error, reason} -> {:error, reason}
end
end
defp validate_email(email) when is_binary(email) do
if String.contains?(email, "@"), do: :ok, else: {:error, :invalid_email}
end
defp validate_email(_), do: {:error, :invalid_email}
defp validate_password(pass) when is_binary(pass) do
if String.length(pass) >= 8, do: :ok, else: {:error, :weak_password}
end
defp validate_password(_), do: {:error, :invalid_password}
defp validate_age(age) when is_integer(age) and age >= 18 do
:ok
end
defp validate_age(_), do: {:error, :invalid_age}
Exercises
- Write a function using
casethat takes a tuple{:ok, value}or{:error, reason}and returns the value or a default - Use
condto categorize a temperature: "Freezing" (< 0), "Cold" (0-15), "Mild" (16-25), "Hot" (> 25) - Write a recursive function to calculate factorial
- Use comprehension to generate pairs of {x, y} where x and y are from 1-5 and x < y
- Use
withto safely chain three operations that might fail
# Solutions
# 1. Case with default
def unwrap(result, default) do
case result do
{:ok, value} -> value
{:error, _} -> default
end
end
# 2. Temperature cond
def categorize_temp(temp) do
cond do
temp < 0 -> "Freezing"
temp <= 15 -> "Cold"
temp <= 25 -> "Mild"
true -> "Hot"
end
end
# 3. Factorial
def factorial(0), do: 1
def factorial(n) when n > 0, do: n * factorial(n - 1)
# 4. Comprehension
for x <- 1..5, y <- 1..5, x < y, do: {x, y}
# 5. With chain
with {:ok, user} <- get_user(id),
{:ok, posts} <- get_posts(user),
{:ok, sorted} <- sort_posts(posts) do
{:ok, {user, sorted}}
end
Next Steps
Continue to 04-functions.md to learn about anonymous functions, named functions, function clauses, guards, and the powerful pipe operator.