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
- Case Expressions
- Cond Expressions
- Common Use Cases
- Best Practices
- Combining Case and Cond
- Conclusion
- Further Reading
- Next Steps
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"
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
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)
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
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
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"
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
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}
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
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}
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
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}
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
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
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
andcond
, 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)