View Source Control Flow

Elixir offers four control flow structures: if/unless, case, cond, and with.

if/2 and unless/2

If-else works as you'd expect. If your condition evaluates to truthy (not nil or false), the code block is executed and the result is returned. If the condition evaluates to falsy (nil or false), the else block is executed if it exists; otherwise, nil is returned.

The reverse form of if/2 is unless/2. If your unless condition evaluates to truthy, the else block is executed. If the condition is falsy, the first block is executed. It is common to choose if over unless because it doesn't make your brain hurt, unless you want to execute a side effect or throw an exception.

age = 17

result =
  if age >= 18 do
    :adult
  else
    :minor
  end

IO.inspect(result, label: 1)

unless age >= 18 do
  raise "A minor tries to access the system!"
end
1: :minor
** (RuntimeError) A minor tries to access the system!

case/2

The case statement is similar to switch in other languages. It allows you to state multiple clauses and tries to match your conditional against each clause.

active_status = :active

fun =
  fn value ->
    case value do
      %{status: ^active_status} -> :active
      %{status: _other_status} -> :inactive

      %{age: nil} -> :age_missing
      %{age: age} when is_integer(age) and age >= 18 -> :adult
      %{age: age} when is_integer(age) -> :minor

      # An optional fallback which always matches.
      # Without it, the case statement would raise an exception if no clause matches.
      _ -> :default
    end
  end

fun.(%{status: :active}) # => :active
fun.(%{status: :foobar}) # => :inactive

fun.(%{age: nil}) # => :age_missing
fun.(%{age: 18}) # => :adult
fun.(%{age: 17}) # => :minor

fun.(nil) # => :default

cond/1

The cond construct allows you to evaluate multiple clauses and return from the first that evaluates to truthy. It is especially useful if you need to execute a function in a clause.

adult? = fn age -> age >= 18 end

fun =
  fn value ->
    cond do
      value == :active -> :active
      is_integer(value) && adult?.(value) -> :adult
      is_integer(value) -> :minor
      # The optional fallback clause must always evaluate to a truthy value.
      # If you don't give this and no other clause matches, Elixir raises an exception.
      true -> :default
    end
  end

fun.(:active) # => :active
fun.(18) # => :adult
fun.(17) # => :minor
fun.(nil) # => :default

with/1

The with/1 statement is useful to return early from a sequence of steps if one step fails. If you find yourself writing nested if-else or case statements, you probably want to use with/1 instead.

adult? = fn age -> age >= 18 end
details = %{name: "Peter", age: 32}

# Instead of writing nested if- or case-statements like this:
check_access = fn details ->
  case details do
    %{age: age} ->
      if adult?.(age) do
        :ok
      else
        {:error, :not_adult}
      end

    _ ->
      {:error, :age_missing}
  end
end

# Rather use one with-statement like this:
check_access_refactored = fn details ->
  with %{age: age} <- details,
       true <- adult?.(age) do
    :ok
  else
    # Gets executed if any of the matches above fails.
    %{} -> {:error, :age_missing}
    false -> {:error, :not_adult}
  end
end

To match inside the else-block isn't great though. What if two matches can return false? How would you know which match failed?

It is good practice to move steps step into small helper functions. This allows you to return a specific error message depending on which step failed and it makes the with-statement more readable. It also makes it easy to see the "happy path" of your function and all its error cases.

defmodule RunElixir.Permissions do
  def check_access(details) do
    with {:ok, age} <- get_age(details),
         :ok <- check_adult(age) do
      :ok
    end
  end

  # Moving each step into a small helper function gives you the flexibility
  # to decide which error to return inside the function.
  defp get_age(details) do
    case details do
      %{age: age} when is_integer(age) -> {:ok, age}
      %{age: _age} -> {:error, :age_invalid}
      _ -> {:error, :age_missing}
    end
  end

  defp check_adult(age) do
    if age >= 18, do: :ok, else: {:error, :not_adult}
  end
end

RunElixir.Permissions.check_access(%{age: 32}) # => :ok
RunElixir.Permissions.check_access(%{age: nil}) # => {:error, :age_invalid}
RunElixir.Permissions.check_access(%{name: "Peter"}) # => {:error, :age_missing}
RunElixir.Permissions.check_access(%{age: 17}) # => {:error, :not_adult}