DEV Community

Cover image for Learning Elixir: Control Flow with Case and Cond
João Paulo Abreu
João Paulo Abreu

Posted on

Learning Elixir: Control Flow with Case and Cond

Pattern matching is a cornerstone of Elixir programming, and case and cond structures provide powerful ways to express complex control flow using pattern matching. This article explores how to effectively use these structures in your Elixir applications.

Note: The examples in this article use Elixir 1.17.3. While most operations should work across different versions, some functionality might vary.

Table of Contents

Introduction

In Elixir, while if and unless are useful for simple true/false decisions, more complex control flow often requires more powerful constructs. This is where case and cond come in. Both are expressions (like everything in Elixir) that return values, but they serve different purposes:

  • case enables pattern matching against a value, making it perfect for handling structured data like tuples and maps. It evaluates an expression and matches the result against one or more patterns until a match is found.

  • cond evaluates multiple conditions in sequence until one evaluates to true, similar to if-else chains in other languages but with a more functional approach and better error handling.

These control structures are fundamental to writing clean, maintainable Elixir code, especially when dealing with complex business logic, error handling, or state transitions.

Case Expressions

The case expression allows you to match a value against multiple patterns. It's particularly useful when working with tuples, maps, and complex data structures.

Basic Case Syntax

# Basic pattern matching
iex> case {1, 2, 3} do
       {4, 5, 6} -> "no match"
       {1, x, 3} -> "matches with x = #{x}"
       _ -> "catch-all" # '_' matches any value (wildcard pattern)
     end
"matches with x = 2"

# Matching with guards
iex> case "hello" do
       x when is_number(x) -> "Got a number"
       x when is_binary(x) -> "Got a string: #{x}"
     end
"Got a string: hello"
Enter fullscreen mode Exit fullscreen mode

Pattern Matching in Case

defmodule UserAuth do
  def handle_login_attempt(response) do
    case response do
      {:ok, %{role: role} = user} when role in [:admin, :manager] ->
        {:ok, "Welcome, #{user.name}! You have admin privileges."}

      {:ok, user} ->
        {:ok, "Welcome, #{user.name}!"}

      {:error, :invalid_credentials} ->
        {:error, "Invalid username or password"}

      {:error, :account_locked} ->
        {:error, "Account is locked. Please contact support."}

      _ -> # '_' catches any unmatched pattern (default case)
        {:error, "An unexpected error occurred"}
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

IEx session with exact outputs:

iex(1)> admin_response = {:ok, %{role: :admin, name: "Alice"}}
{:ok, %{name: "Alice", role: :admin}}

iex(2)> UserAuth.handle_login_attempt(admin_response)
{:ok, "Welcome, Alice! You have admin privileges."}

iex(3)> manager_response = {:ok, %{role: :manager, name: "Bob"}}
{:ok, %{name: "Bob", role: :manager}}

iex(4)> UserAuth.handle_login_attempt(manager_response)
{:ok, "Welcome, Bob! You have admin privileges."}

iex(5)> regular_response = {:ok, %{role: :user, name: "Carol"}}
{:ok, %{name: "Carol", role: :user}}

iex(6)> UserAuth.handle_login_attempt(regular_response)
{:ok, "Welcome, Carol!"}

iex(7)> invalid_response = {:error, :invalid_credentials}
{:error, :invalid_credentials}

iex(8)> UserAuth.handle_login_attempt(invalid_response)
{:error, "Invalid username or password"}

iex(9)> locked_response = {:error, :account_locked}
{:error, :account_locked}

iex(10)> UserAuth.handle_login_attempt(locked_response)
{:error, "Account is locked. Please contact support."}

iex(11)> unexpected_response = {:something_else, "unexpected"}
{:something_else, "unexpected"}

iex(12)> UserAuth.handle_login_attempt(unexpected_response)
{:error, "An unexpected error occurred"}

iex(13)> incomplete_response = {:ok, %{role: :admin}}  # Missing name
{:ok, %{role: :admin}}

iex(14)> UserAuth.handle_login_attempt(incomplete_response)
** (KeyError) key :name not found in: %{role: :admin}
    iex:7: UserAuth.handle_login_attempt/1
    iex:14: (file)
Enter fullscreen mode Exit fullscreen mode

Working with Maps and Structs

defmodule OrderProcessor do
  def process_order(order) do
    case order do
      %{status: "pending", items: items} when length(items) > 0 ->
        {:ok, calculate_total(items)}

      %{status: "cancelled"} ->
        {:error, :order_cancelled}

      %{items: []} ->
        {:error, :empty_order}

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

  defp calculate_total(items) do
    Enum.reduce(items, 0, fn %{price: price}, acc -> acc + price end)
  end
end
Enter fullscreen mode Exit fullscreen mode

IEx test session with exact outputs:

# Test 1: Valid pending order with items
valid_order = %{
  status: "pending",
  items: [
    %{name: "Book", price: 20},
    %{name: "Coffee", price: 5},
    %{name: "Pen", price: 2}
  ]
}
%{
  status: "pending",
  items: [
    %{name: "Book", price: 20},
    %{name: "Coffee", price: 5},
    %{name: "Pen", price: 2}
  ]
}

OrderProcessor.process_order(valid_order)
{:ok, 27}

# Test 2: Cancelled order
cancelled_order = %{status: "cancelled", items: [%{name: "Book", price: 20}]}
%{status: "cancelled", items: [%{name: "Book", price: 20}]}

OrderProcessor.process_order(cancelled_order)
{:error, :order_cancelled}

# Test 3: Empty order
empty_order = %{status: "pending", items: []}
%{status: "pending", items: []}

OrderProcessor.process_order(empty_order)
{:error, :empty_order}

# Test 4: Invalid order (missing required fields)
invalid_order = %{something: "wrong"}
%{something: "wrong"}

OrderProcessor.process_order(invalid_order)
{:error, :invalid_order}

# Test 5: Order with invalid item structure (missing price)
invalid_items_order = %{
  status: "pending",
  items: [%{name: "Book"}]
}
%{status: "pending", items: [%{name: "Book"}]}

OrderProcessor.process_order(invalid_items_order)
** (FunctionClauseError) no function clause matching in anonymous fn/2 in OrderProcessor.calculate_total/1    

    The following arguments were given to anonymous fn/2 in OrderProcessor.calculate_total/1:

        # 1
        %{name: "Book"}

        # 2
        0

    iex:36: anonymous fn/2 in OrderProcessor.calculate_total/1
    (elixir 1.17.3) lib/enum.ex:2531: Enum."-reduce/3-lists^foldl/2-0-"/3
    iex:22: OrderProcessor.process_order/1
Enter fullscreen mode Exit fullscreen mode

Cond Expressions

The cond expression is useful when you need to match against conditions rather than patterns. It evaluates each condition in order until it finds one that's true.

Basic Cond Syntax

iex> cond do
       2 + 2 == 5 -> "This is wrong"
       2 * 2 == 4 -> "This is correct"
       true -> "This is the default"
     end
"This is correct"
Enter fullscreen mode Exit fullscreen mode

Multiple Conditions

defmodule GradeCalculator do
  def letter_grade(score) do
    cond do
      score >= 90 -> "A"
      score >= 80 -> "B"
      score >= 70 -> "C"
      score >= 60 -> "D"
      true -> "F"
    end
  end

  def pass_fail(score) do
    cond do
      is_number(score) and score >= 60 -> :pass
      is_number(score) -> :fail
      true -> {:error, :invalid_score}
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

IEx test session with exact outputs:

# Testing letter_grade/1 function

# Test each grade boundary
GradeCalculator.letter_grade(95)
"A"

GradeCalculator.letter_grade(90)  # Lower bound for A
"A"

GradeCalculator.letter_grade(85)
"B"

GradeCalculator.letter_grade(80)  # Lower bound for B
"B"

GradeCalculator.letter_grade(75)
"C"

GradeCalculator.letter_grade(70)  # Lower bound for C
"C"

GradeCalculator.letter_grade(65)
"D"

GradeCalculator.letter_grade(60)  # Lower bound for D
"D"

GradeCalculator.letter_grade(55)
"F"

# Test extreme values
GradeCalculator.letter_grade(100)  # Perfect score
"A"

GradeCalculator.letter_grade(0)    # Zero
"F"

GradeCalculator.letter_grade(-10)  # Negative score
"F"

# Testing pass_fail/1 function

# Test passing scores
GradeCalculator.pass_fail(90)
:pass

GradeCalculator.pass_fail(60)  # Minimum passing score
:pass

# Test failing scores
GradeCalculator.pass_fail(59)  # Just below passing
:fail

GradeCalculator.pass_fail(0)
:fail

# Test invalid inputs
GradeCalculator.pass_fail("90")  # String instead of number
{:error, :invalid_score}

GradeCalculator.pass_fail(:invalid)  # Atom instead of number
{:error, :invalid_score}

GradeCalculator.pass_fail([90])  # List instead of number
{:error, :invalid_score}

GradeCalculator.pass_fail(%{score: 90})  # Map instead of number
{:error, :invalid_score}
Enter fullscreen mode Exit fullscreen mode

Common Use Cases

Error Handling with Case

defmodule APIClient do
  def process_response(response) do
    case response do
      {:ok, %{status: 200, body: body}} ->
        {:ok, decode_body(body)}

      {:ok, %{status: 404}} ->
        {:error, :not_found}

      {:ok, %{status: status}} when status >= 500 ->
        {:error, :server_error}

      {:error, %{reason: reason}} ->
        {:error, reason}

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

  defp decode_body(body) do
    # Body decoding logic here
    body
  end
end
Enter fullscreen mode Exit fullscreen mode

IEx test session with exact outputs:

success_response = {:ok, %{status: 200, body: "response data"}}
{:ok, %{status: 200, body: "response data"}}

APIClient.process_response(success_response)
{:ok, "response data"}

success_response_json = {:ok, %{status: 200, body: %{data: "some data"}}}
{:ok, %{status: 200, body: %{data: "some data"}}}

APIClient.process_response(success_response_json)
{:ok, %{data: "some data"}}

not_found_response = {:ok, %{status: 404}}
{:ok, %{status: 404}}

APIClient.process_response(not_found_response)
{:error, :not_found}

server_error_response = {:ok, %{status: 500}}
{:ok, %{status: 500}}

APIClient.process_response(server_error_response)
{:error, :server_error}

bad_gateway_response = {:ok, %{status: 502}}
{:ok, %{status: 502}}

APIClient.process_response(bad_gateway_response)
{:error, :server_error}

error_with_reason = {:error, %{reason: :timeout}}
{:error, %{reason: :timeout}}

APIClient.process_response(error_with_reason)
{:error, :timeout}

unknown_response = {:error, "unexpected error"}
{:error, "unexpected error"}

APIClient.process_response(unknown_response)
{:error, :unknown_error}

invalid_response = "not a proper response"
"not a proper response"

APIClient.process_response(invalid_response)
{:error, :unknown_error}

invalid_success = {:ok, %{status: 200}}
{:ok, %{status: 200}}

APIClient.process_response(invalid_success)
{:error, :unknown_error}
Enter fullscreen mode Exit fullscreen mode

State Machine Transitions with Cond

defmodule OrderState do
  def next_state(current_state, action) do
    cond do
      current_state == :pending and action == :approve ->
        {:ok, :approved}

      current_state == :approved and action == :ship ->
        {:ok, :shipped}

      current_state == :shipped and action == :deliver ->
        {:ok, :delivered}

      current_state == :pending and action == :cancel ->
        {:ok, :cancelled}

      true ->
        {:error, :invalid_transition}
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

IEx test session with exact outputs:

# Test valid transitions

# From pending state
OrderState.next_state(:pending, :approve)
{:ok, :approved}

OrderState.next_state(:pending, :cancel)
{:ok, :cancelled}

# From approved state
OrderState.next_state(:approved, :ship)
{:ok, :shipped}

# From shipped state
OrderState.next_state(:shipped, :deliver)
{:ok, :delivered}

# Test invalid transitions

# Invalid actions for pending state
OrderState.next_state(:pending, :ship)
{:error, :invalid_transition}

OrderState.next_state(:pending, :deliver)
{:error, :invalid_transition}

# Invalid actions for approved state
OrderState.next_state(:approved, :approve)
{:error, :invalid_transition}

OrderState.next_state(:approved, :cancel)
{:error, :invalid_transition}

OrderState.next_state(:approved, :deliver)
{:error, :invalid_transition}

# Invalid actions for shipped state
OrderState.next_state(:shipped, :approve)
{:error, :invalid_transition}

OrderState.next_state(:shipped, :cancel)
{:error, :invalid_transition}

# Invalid actions for delivered state
OrderState.next_state(:delivered, :approve)
{:error, :invalid_transition}

OrderState.next_state(:delivered, :ship)
{:error, :invalid_transition}

# Test with invalid states
OrderState.next_state(:invalid_state, :approve)
{:error, :invalid_transition}

# Test with invalid actions
OrderState.next_state(:pending, :invalid_action)
{:error, :invalid_transition}
Enter fullscreen mode Exit fullscreen mode

Best Practices

When to Use Case vs Cond

  • Use case when:

    • Pattern matching against a specific value or structure
    • Working with tagged tuples (e.g., {:ok, result} or {:error, reason})
    • Matching against complex data structures
  • Use cond when:

    • Testing multiple independent conditions
    • Implementing if-else chains
    • Working with boolean expressions
    • Building state machines

Avoiding Deep Nesting

Instead of nesting case or cond expressions, prefer to:

  • Break complex logic into smaller functions
  • Use pattern matching in function heads
  • Consider using the with special form (covered in the next article)
# Avoid this:
defmodule Deep do
  def process(data) do
    case validate(data) do
      {:ok, valid_data} ->
        case transform(valid_data) do
          {:ok, transformed} ->
            case save(transformed) do
              {:ok, result} -> {:ok, result}
              error -> error
            end
          error -> error
        end
      error -> error
    end
  end
end

# Prefer this:
defmodule Flat do
  def process(data) do
    with {:ok, valid_data} <- validate(data),
         {:ok, transformed} <- transform(valid_data),
         {:ok, result} <- save(transformed) do
      {:ok, result}
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Combining Case and Cond

Sometimes you might need to use both case and cond together:

defmodule PaymentProcessor do
  def process_payment(payment) do
    case validate_payment(payment) do
      {:ok, validated_payment} ->
        status = cond do
          validated_payment.amount > 1000 -> :needs_approval
          validated_payment.currency != "USD" -> :needs_conversion
          true -> :ready_to_process
        end
        {:ok, status}

      {:error, reason} ->
        {:error, reason}
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Conclusion

case and cond are powerful control structures that enable expressive pattern matching and conditional logic in Elixir. While case excels at pattern matching against values and structures, cond is perfect for evaluating multiple independent conditions. Understanding when to use each one is key to writing clear and maintainable Elixir code.

Tip: When deciding between case and cond, ask yourself: "Am I matching against patterns of a specific value (case) or evaluating multiple independent conditions (cond)?"

Further Reading

Next Steps

In the upcoming article, we'll explore the with special form:

With Structure

  • Using with for cleaner control flow
  • Error handling with with
  • Best practices for with expressions
  • Common patterns and use cases

Top comments (0)