Elixir elevates pattern matching to a superior level by integrating it directly into function definitions, creating a truly declarative programming style. This integration allows you to abandon traditional conditional structures in favor of multiple function clauses, each specialized in handling a specific input pattern. The result? Code that communicates its intent with exceptional clarity, reduces common logical errors, and practically describes itself. In this article, we'll explore how this powerful technique transforms the way we write functions, making them more expressive, concise, and inherently robust.
Note: The examples in this article use Elixir 1.18.3. While most operations should work across different versions, some functionality might vary.
Table of Contents
- Introduction
- Function Clauses and Pattern Matching
- Pattern Matching on Data Structures
- Guards with Pattern Matching
- Destructuring Complex Arguments
- Pattern Matching on Binaries
- Pattern Matching for Control Flow
- Common Design Patterns
- Best Practices
- Conclusion
- Further Reading
- Next Steps
Introduction
In previous articles, we've explored anonymous functions and named functions in Elixir. We briefly touched on pattern matching in function definitions, but this concept is so fundamental to Elixir that it deserves deeper exploration.
Pattern matching in functions allows you to:
- Define multiple function clauses for the same function name
- Select the appropriate clause based on the structure and content of the arguments
- Destructure complex data structures directly in function parameters
- Express conditional logic based on input patterns rather than using if/else statements
- Handle different cases declaratively rather than imperatively
Let's dive into this powerful feature that is central to idiomatic Elixir programming.
Function Clauses and Pattern Matching
The core of pattern matching in functions is the ability to define multiple function clauses. Each clause has the same function name and arity (number of arguments), but different parameter patterns.
Basic Syntax
Here's the basic syntax for defining multiple function clauses:
def function_name(pattern1), do: result1
def function_name(pattern2), do: result2
def function_name(pattern3), do: result3
When you call the function, Elixir tries to match the arguments against each pattern in the order they are defined, and executes the first matching clause.
Simple Example
Let's start with a simple example:
defmodule Greeting do
def say_hello(""), do: "Hello, stranger!"
def say_hello(name), do: "Hello, #{name}!"
end
Testing in IEx:
iex> Greeting.say_hello("")
"Hello, stranger!"
iex> Greeting.say_hello("Alice")
"Hello, Alice!"
When we call Greeting.say_hello("")
, Elixir matches the argument against the first clause because it matches the pattern ""
(an empty string). For any other string, the first clause doesn't match, so it tries the second clause, which matches any value and binds it to the variable name
.
Order Matters
The order of function clauses is critical, as Elixir tries to match them in the order they are defined:
defmodule OrderExample do
# This clause matches any input and binds it to 'x'
def process(x), do: "Caught everything: #{inspect(x)}"
# This clause will never be reached because the clause above catches everything
def process(0), do: "Zero"
end
Testing in IEx:
iex> OrderExample.process(0)
"Caught everything: 0"
iex> OrderExample.process(42)
"Caught everything: 42"
The correct order would be:
defmodule OrderExample do
# Specific patterns should come first
def process(0), do: "Zero"
# More general patterns should come later
def process(x), do: "Caught everything: #{inspect(x)}"
end
Testing in IEx:
iex> OrderExample.process(0)
"Zero"
iex> OrderExample.process(42)
"Caught everything: 42"
Pattern Matching on Multiple Arguments
You can use pattern matching on multiple function arguments:
defmodule Calculator do
def operate(0, 0, "+"), do: "Cannot add zeros meaningfully"
def operate(a, b, "+"), do: a + b
def operate(a, 0, "/"), do: {:error, "Division by zero"}
def operate(a, b, "/"), do: a / b
def operate(a, b, "*") when a == 0 or b == 0, do: 0
def operate(a, b, "*"), do: a * b
def operate(a, b, "-"), do: a - b
def operate(_, _, op), do: {:error, "Unknown operator: #{op}"}
end
Testing in IEx:
iex> Calculator.operate(5, 3, "+")
8
iex> Calculator.operate(10, 0, "/")
{:error, "Division by zero"}
iex> Calculator.operate(0, 0, "+")
"Cannot add zeros meaningfully"
iex> Calculator.operate(5, 0, "*")
0
iex> Calculator.operate(10, 2, "@")
{:error, "Unknown operator: @"}
Pattern Matching on Data Structures
Pattern matching becomes particularly powerful when dealing with complex data structures like tuples, lists, and maps.
Pattern Matching on Tuples
Tuples are frequently used for return values in Elixir, often to indicate success or failure. Pattern matching on tuples allows you to handle different result types elegantly:
defmodule ResultHandler do
def handle({:ok, value}), do: "Success: #{inspect(value)}"
def handle({:error, reason}), do: "Error: #{reason}"
def handle(other), do: "Unknown result: #{inspect(other)}"
end
Testing in IEx:
iex> ResultHandler.handle({:ok, 42})
"Success: 42"
iex> ResultHandler.handle({:error, "not found"})
"Error: not found"
iex> ResultHandler.handle(:something_else)
"Unknown result: :something_else"
Pattern Matching on Lists
Pattern matching on lists is commonly used for recursion, where you handle the head and tail separately:
defmodule ListProcessor do
# Base case: empty list
def sum([]), do: 0
# Recursive case: extract head and process tail
def sum([head | tail]), do: head + sum(tail)
# Pattern match on specific list patterns
def describe([]), do: "Empty list"
def describe([_]), do: "Single-element list"
def describe([_, _]), do: "Two-element list"
def describe([_, _, _]), do: "Three-element list"
def describe(list) when is_list(list), do: "List with #{length(list)} elements"
end
Testing in IEx:
iex> ListProcessor.sum([])
0
iex> ListProcessor.sum([1, 2, 3, 4, 5])
15
iex> ListProcessor.describe([])
"Empty list"
iex> ListProcessor.describe([1])
"Single-element list"
iex> ListProcessor.describe([1, 2])
"Two-element list"
iex> ListProcessor.describe([1, 2, 3])
"Three-element list"
iex> ListProcessor.describe([1, 2, 3, 4])
"List with 4 elements"
You can also match specific values within lists:
defmodule ListMatcher do
# Match a list starting with 1
def process([1 | _]), do: "List starts with 1"
# Match a list containing exactly [2, 3]
def process([2, 3]), do: "List is [2, 3]"
# Match a list ending with 5
def process(list) when is_list(list) and length(list) > 0 do
if List.last(list) == 5, do: "List ends with 5", else: "Other list"
end
# Catch-all
def process(_), do: "Not a list or empty list"
end
Testing in IEx:
iex> ListMatcher.process([1, 2, 3])
"List starts with 1"
iex> ListMatcher.process([2, 3])
"List is [2, 3]"
iex> ListMatcher.process([3, 4, 5])
"List ends with 5"
iex> ListMatcher.process([4, 5, 6])
"Other list"
Pattern Matching on Maps
When working with maps, you can pattern match on specific keys and values:
defmodule UserProcessor do
# Match map with both name and age keys
def process(%{name: name, age: age}) when age >= 18, do: "Adult user: #{name}, #{age}"
def process(%{name: name, age: age}), do: "Minor user: #{name}, #{age}"
# Match on specific values - this is quite specific
def process(%{role: "admin"}), do: "Administrator"
# Match map with exactly name key (less specific than those above)
def process(%{name: name}), do: "User name: #{name}"
# Match empty map (using map_size for clarity)
def process(map) when is_map(map) and map_size(map) == 0, do: "Empty map"
# Catch-all
def process(_), do: "Not a user map"
end
Testing in IEx:
iex> UserProcessor.process(%{})
"Empty map"
iex> UserProcessor.process(%{name: "Alice"})
"User name: Alice"
iex> UserProcessor.process(%{name: "Bob", age: 25})
"Adult user: Bob, 25"
iex> UserProcessor.process(%{name: "Charlie", age: 15})
"Minor user: Charlie, 15"
iex> UserProcessor.process(%{role: "admin"})
"Administrator"
iex> UserProcessor.process("not a map")
"Not a user map"
Guards with Pattern Matching
Guards allow you to extend pattern matching with additional conditions:
defmodule NumberClassifier do
# Pattern matching with guards for integers
def classify(x) when is_integer(x) and x < 0, do: "negative integer"
def classify(0), do: "zero"
def classify(x) when is_integer(x) and x > 0, do: "positive integer"
# Pattern matching with guards for floats
def classify(x) when is_float(x) and x < 0, do: "negative float"
def classify(x) when is_float(x) and x == 0.0, do: "zero as float"
def classify(x) when is_float(x) and x > 0, do: "positive float"
# Catch-all
def classify(_), do: "not a number"
end
Testing in IEx:
iex> NumberClassifier.classify(-42)
"negative integer"
iex> NumberClassifier.classify(0)
"zero"
iex> NumberClassifier.classify(42)
"positive integer"
iex> NumberClassifier.classify(-3.14)
"negative float"
iex> NumberClassifier.classify(0.0)
"zero as float"
iex> NumberClassifier.classify(3.14)
"positive float"
iex> NumberClassifier.classify("42")
"not a number"
Complex Guard Combinations
Guards can be combined using logical operators:
defmodule InputValidator do
# Valid input: string between 2 and 10 characters
def validate(input) when is_binary(input) and byte_size(input) >= 2 and byte_size(input) <= 10 do
"Valid string input"
end
# Valid input: positive integer less than 100
def validate(input) when is_integer(input) and input > 0 and input < 100 do
"Valid integer input"
end
# Valid input: list with 1 to 5 elements
def validate(input) when is_list(input) and length(input) > 0 and length(input) <= 5 do
"Valid list input"
end
# Invalid input
def validate(_), do: "Invalid input"
end
Testing in IEx:
iex> InputValidator.validate("hello")
"Valid string input"
iex> InputValidator.validate("a")
"Invalid input"
iex> InputValidator.validate("this is too long")
"Invalid input"
iex> InputValidator.validate(42)
"Valid integer input"
iex> InputValidator.validate(-5)
"Invalid input"
iex> InputValidator.validate([1, 2, 3])
"Valid list input"
iex> InputValidator.validate([1, 2, 3, 4, 5, 6])
"Invalid input"
Destructuring Complex Arguments
Destructuring allows you to extract and bind parts of complex data structures directly in function parameters.
Nested Destructuring
You can destructure nested data structures:
defmodule DeepMatcher do
# Match a map containing a user key with a nested map containing a name key
def extract(%{user: %{name: name}}), do: "User name: #{name}"
# Match a tuple containing a tuple
def extract({{x, y}, z}), do: "Coordinates: (#{x}, #{y}, #{z})"
# Match a list containing a list - com correção para interpolação segura
def extract([head | [subhead | _]]), do: "First two elements: #{head}, #{inspect(subhead)}"
# Catch-all
def extract(_), do: "No pattern matched"
end
Testing in IEx:
iex> DeepMatcher.extract(%{user: %{name: "Alice", age: 30}})
"User name: Alice"
iex> DeepMatcher.extract({{1, 2}, 3})
"Coordinates: (1, 2, 3)"
iex> DeepMatcher.extract([10, [20, 30]])
"First two elements: 10, [20, 30]"
iex> DeepMatcher.extract([10, 20, 30])
"First two elements: 10, 20"
Binding and Destructuring
You can both bind a value and destructure it:
defmodule ApiResponseHandler do
# Match and bind the entire response while also destructuring it
def handle(response = %{status: 200, body: body}) do
"Success response: #{inspect(response)}, Body: #{inspect(body)}"
end
def handle(response = %{status: status}) when status >= 400 and status < 500 do
"Client error: #{inspect(response)}"
end
def handle(response = %{status: status}) when status >= 500 do
"Server error: #{inspect(response)}"
end
def handle(response), do: "Unknown response: #{inspect(response)}"
end
Testing in IEx:
iex> ApiResponseHandler.handle(%{status: 200, body: "data"})
"Success response: %{status: 200, body: \"data\"}, Body: \"data\""
iex> ApiResponseHandler.handle(%{status: 404, message: "Not found"})
"Client error: %{message: \"Not found\", status: 404}"
iex> ApiResponseHandler.handle(%{status: 500, error: "Internal error"})
"Server error: %{error: \"Internal error\", status: 500}"
Pattern Matching on Binaries
Elixir allows pattern matching on binary data, which is particularly useful for parsing binary protocols or file formats.
Matching String Patterns
defmodule StringParser do
# Match strings that start with "http://"
def parse("http://" <> rest), do: {:http, rest}
# Match strings that start with "https://"
def parse("https://" <> rest), do: {:https, rest}
# Match strings that contain "@" (like email addresses)
def parse(email) when is_binary(email) and binary_part(email, 0, 1) != "@" do
case String.split(email, "@", parts: 2) do
[username, domain] -> {:email, username, domain}
_ -> {:unknown, email}
end
end
# Catch-all
def parse(binary), do: {:unknown, binary}
end
Testing in IEx:
iex> StringParser.parse("http://example.com")
{:http, "example.com"}
iex> StringParser.parse("https://secure.example.com")
{:https, "secure.example.com"}
iex> StringParser.parse("user@example.com")
{:email, "user", "example.com"}
iex> StringParser.parse("just plain text")
{:unknown, "just plain text"}
Binary Pattern Matching
For more complex binary pattern matching:
defmodule BinaryParser do
# Match a binary starting with a byte with value 1, followed by a 16-bit integer
def parse(<<1, value::16, rest::binary>>) do
{:type_1, value, rest}
end
# Match a binary starting with a byte with value 2, followed by a 32-bit integer
def parse(<<2, value::32, rest::binary>>) do
{:type_2, value, rest}
end
# Match a binary starting with a byte with value 3, followed by a string of specific length
def parse(<<3, string_length::8, string::binary-size(string_length), rest::binary>>) do
{:type_3, string, rest}
end
# Catch-all
def parse(binary), do: {:unknown, binary}
end
Testing in IEx:
iex> BinaryParser.parse(<<1, 0, 5, "rest">>)
{:type_1, 5, "rest"}
iex> BinaryParser.parse(<<2, 0, 0, 0, 10, "rest">>)
{:type_2, 10, "rest"}
iex> BinaryParser.parse(<<3, 5, "hello", "rest">>)
{:type_3, "hello", "rest"}
iex> BinaryParser.parse(<<4, 1, 2, 3>>)
{:unknown, <<4, 1, 2, 3>>}
Pattern Matching for Control Flow
Pattern matching is often used as an alternative to traditional conditional statements, making the code more declarative.
Replacing if/else Statements
Instead of using if/else statements, you can use pattern matching in function clauses:
defmodule AgeChecker do
# Traditional approach with if/else
def check_age(user) do
if user.age >= 18 do
"#{user.name} is an adult"
else
"#{user.name} is a minor"
end
end
# Pattern matching approach with guards
def check_age_pm(%{name: name, age: age}) when age >= 18, do: "#{name} is an adult"
def check_age_pm(%{name: name, age: _}), do: "#{name} is a minor"
end
Testing in IEx:
iex> AgeChecker.check_age(%{name: "Alice", age: 30})
"Alice is an adult"
iex> AgeChecker.check_age_pm(%{name: "Bob", age: 15})
"Bob is a minor"
Replacing case Statements
Similarly, pattern matching can replace case statements:
defmodule ResultProcessor do
# Traditional approach with case
def process_with_case(result) do
case result do
{:ok, value} -> "Success: #{value}"
{:error, reason} -> "Error: #{reason}"
_ -> "Unknown result"
end
end
# Pattern matching approach with function clauses
def process_result({:ok, value}), do: "Success: #{value}"
def process_result({:error, reason}), do: "Error: #{reason}"
def process_result(_), do: "Unknown result"
end
Testing in IEx:
iex> ResultProcessor.process_with_case({:ok, "data"})
"Success: data"
iex> ResultProcessor.process_with_case({:error, "timeout"})
"Error: timeout"
iex> ResultProcessor.process_with_case(:something_else)
"Unknown result"
iex> ResultProcessor.process_result({:ok, "data"})
"Success: data"
iex> ResultProcessor.process_result({:error, "timeout"})
"Error: timeout"
iex> ResultProcessor.process_result(:something_else)
"Unknown result"
Complete Example: HTTP Request Handler
Let's see a more complete example of using pattern matching for control flow:
defmodule HttpRequestHandler do
# GET request for the root path
def handle(%{method: "GET", path: "/"}) do
{:ok, 200, "Welcome to the homepage"}
end
# GET request for the users path
def handle(%{method: "GET", path: "/users"}) do
users = ["Alice", "Bob", "Charlie"]
{:ok, 200, "Users: #{Enum.join(users, ", ")}"}
end
# GET request for a specific user
def handle(%{method: "GET", path: "/users/" <> user_id}) do
# In a real app, we would fetch from a database
case user_id do
"1" -> {:ok, 200, "User: Alice"}
"2" -> {:ok, 200, "User: Bob"}
"3" -> {:ok, 200, "User: Charlie"}
_ -> {:error, 404, "User not found"}
end
end
# POST request to create a user
def handle(%{method: "POST", path: "/users", body: body}) do
# In a real app, we would validate and save to a database
{:ok, 201, "Created user: #{body}"}
end
# DELETE request for a specific user
def handle(%{method: "DELETE", path: "/users/" <> user_id}) do
# In a real app, we would delete from a database
{:ok, 204, "Deleted user #{user_id}"}
end
# Catch-all for unknown routes
def handle(%{path: path}) do
{:error, 404, "Not found: #{path}"}
end
# Catch-all for malformed requests
def handle(_) do
{:error, 400, "Bad request"}
end
end
Testing in IEx:
iex> HttpRequestHandler.handle(%{method: "GET", path: "/"})
{:ok, 200, "Welcome to the homepage"}
iex> HttpRequestHandler.handle(%{method: "GET", path: "/users"})
{:ok, 200, "Users: Alice, Bob, Charlie"}
iex> HttpRequestHandler.handle(%{method: "GET", path: "/users/2"})
{:ok, 200, "User: Bob"}
iex> HttpRequestHandler.handle(%{method: "GET", path: "/users/99"})
{:error, 404, "User not found"}
iex> HttpRequestHandler.handle(%{method: "POST", path: "/users", body: "Dave"})
{:ok, 201, "Created user: Dave"}
iex> HttpRequestHandler.handle(%{method: "DELETE", path: "/users/3"})
{:ok, 204, "Deleted user 3"}
iex> HttpRequestHandler.handle(%{method: "GET", path: "/products"})
{:error, 404, "Not found: /products"}
iex> HttpRequestHandler.handle("invalid request")
{:error, 400, "Bad request"}
Common Design Patterns
Let's look at some common design patterns that leverage pattern matching in functions.
State Machine Implementation
Pattern matching is ideal for implementing state machines:
defmodule TrafficLight do
# Initial state
def transition(:red), do: :green
# Green light transitions to yellow
def transition(:green), do: :yellow
# Yellow light transitions to red
def transition(:yellow), do: :red
# Unknown state transitions to red (safe default)
def transition(_), do: :red
# Simulate a traffic light cycle
def simulate_cycle(initial_state, cycles) do
stream = Stream.iterate(initial_state, &transition/1)
Enum.take(stream, cycles)
end
end
Testing in IEx:
iex> TrafficLight.transition(:red)
:green
iex> TrafficLight.transition(:green)
:yellow
iex> TrafficLight.transition(:yellow)
:red
iex> TrafficLight.simulate_cycle(:red, 6)
[:red, :green, :yellow, :red, :green, :yellow]
Builder Pattern
Pattern matching enables an elegant builder pattern:
defmodule QueryBuilder do
# Start with an empty query
def build, do: %{select: "*", from: nil, where: [], order_by: nil, limit: nil}
# Add FROM clause
def from(query, table), do: %{query | from: table}
# Add SELECT clause
def select(query, fields) when is_list(fields), do: %{query | select: Enum.join(fields, ", ")}
def select(query, field), do: %{query | select: field}
# Add WHERE clause (multiple conditions can be added)
def where(query, condition), do: %{query | where: query.where ++ [condition]}
# Add ORDER BY clause
def order_by(query, field), do: %{query | order_by: field}
# Add LIMIT clause
def limit(query, value) when is_integer(value) and value > 0, do: %{query | limit: value}
# Build SQL string (simplified for demonstration)
def to_sql(%{select: select, from: from} = query) when not is_nil(from) do
sql = "SELECT #{select} FROM #{from}"
sql = if length(query.where) > 0 do
conditions = Enum.join(query.where, " AND ")
"#{sql} WHERE #{conditions}"
else
sql
end
sql = if query.order_by do
"#{sql} ORDER BY #{query.order_by}"
else
sql
end
sql = if query.limit do
"#{sql} LIMIT #{query.limit}"
else
sql
end
sql
end
# Error case when FROM is missing
def to_sql(_), do: {:error, "FROM clause is required"}
end
Testing in IEx:
iex> query = QueryBuilder.build()
%{select: "*", where: [], limit: nil, from: nil, order_by: nil}
iex> query = QueryBuilder.from(query, "users")
%{select: "*", where: [], limit: nil, from: "users", order_by: nil}
iex> query = QueryBuilder.select(query, ["id", "name", "email"])
%{
select: "id, name, email",
where: [],
limit: nil,
from: "users",
order_by: nil
}
iex> query = QueryBuilder.where(query, "age > 18")
%{
select: "id, name, email",
where: ["age > 18"],
limit: nil,
from: "users",
order_by: nil
}
iex> query = QueryBuilder.where(query, "status = 'active'")
%{
select: "id, name, email",
where: ["age > 18", "status = 'active'"],
limit: nil,
from: "users",
order_by: nil
}
iex> query = QueryBuilder.order_by(query, "name ASC")
%{
select: "id, name, email",
where: ["age > 18", "status = 'active'"],
limit: nil,
from: "users",
order_by: "name ASC"
}
iex> query = QueryBuilder.limit(query, 10)
%{
select: "id, name, email",
where: ["age > 18", "status = 'active'"],
limit: 10,
from: "users",
order_by: "name ASC"
}
iex> QueryBuilder.to_sql(query)
"SELECT id, name, email FROM users WHERE age > 18 AND status = 'active' ORDER BY name ASC LIMIT 10"
iex> incomplete_query = QueryBuilder.build() |> QueryBuilder.select("name")
%{select: "name", where: [], limit: nil, from: nil, order_by: nil}
iex> QueryBuilder.to_sql(incomplete_query)
{:error, "FROM clause is required"}
Strategy Pattern
Pattern matching allows for elegant implementation of the strategy pattern:
defmodule PaymentProcessor do
def process_payment(%{method: "credit_card"} = payment) do
# Credit card specific processing
"Processing credit card payment of #{payment.amount}"
end
def process_payment(%{method: "paypal"} = payment) do
# PayPal specific processing
"Processing PayPal payment of #{payment.amount}"
end
def process_payment(%{method: "bank_transfer"} = payment) do
# Bank transfer specific processing
"Processing bank transfer of #{payment.amount}"
end
def process_payment(%{method: "crypto", currency: currency} = payment) do
# Cryptocurrency specific processing
"Processing #{currency} payment of #{payment.amount}"
end
def process_payment(payment) do
"Unsupported payment method: #{inspect(payment)}"
end
end
Testing in IEx:
iex> PaymentProcessor.process_payment(%{method: "credit_card", amount: 100})
"Processing credit card payment of 100"
iex> PaymentProcessor.process_payment(%{method: "paypal", amount: 75.50})
"Processing PayPal payment of 75.5"
iex> PaymentProcessor.process_payment(%{method: "bank_transfer", amount: 250})
"Processing bank transfer of 250"
iex> PaymentProcessor.process_payment(%{method: "crypto", currency: "Bitcoin", amount: 0.05})
"Processing Bitcoin payment of 0.05"
iex> PaymentProcessor.process_payment(%{method: "check", amount: 150})
"Unsupported payment method: %{amount: 150, method: \"check\"}"
Best Practices
Here are some best practices to consider when using pattern matching in functions:
Order Function Clauses from Specific to General
Always arrange function clauses from most specific to most general:
# Good: specific to general
def process([]), do: "Empty list"
def process([_]), do: "Single-element list"
def process([_, _]), do: "Two-element list"
def process(list) when is_list(list), do: "List with #{length(list)} elements"
def process(_), do: "Not a list"
# Bad: general clause will catch everything
def process(_), do: "Not a list"
def process(list) when is_list(list), do: "List with #{length(list)} elements"
def process([_, _]), do: "Two-element list" # Never reached
def process([_]), do: "Single-element list" # Never reached
def process([]), do: "Empty list" # Never reached
Use Guards to Clarify Intent
Guards make your functions more explicit and self-documenting:
# Without guards - less clear
def process(n) do
cond do
n < 0 -> "negative"
n == 0 -> "zero"
n > 0 -> "positive"
end
end
# With guards - more explicit
def process(n) when n < 0, do: "negative"
def process(0), do: "zero"
def process(n) when n > 0, do: "positive"
Prefer Pattern Matching Over Conditionals
Pattern matching often leads to more readable and maintainable code than conditionals:
# Less idiomatic: using conditionals
def handle_result(result) do
if is_tuple(result) and tuple_size(result) == 2 do
case elem(result, 0) do
:ok -> "Success: #{elem(result, 1)}"
:error -> "Error: #{elem(result, 1)}"
_ -> "Unknown tuple format"
end
else
"Not a valid result tuple"
end
end
# More idiomatic: using pattern matching
def handle_result({:ok, value}), do: "Success: #{value}"
def handle_result({:error, reason}), do: "Error: #{reason}"
def handle_result(_), do: "Not a valid result tuple"
Use Destructuring to Make Intent Clear
Destructuring makes the expected input structure obvious:
# Less clear - uses access syntax
def process_user(user) do
"User: #{user[:name]}, Age: #{user[:age]}"
end
# More clear - uses destructuring to show required structure
def process_user(%{name: name, age: age}) do
"User: #{name}, Age: #{age}"
end
Avoid Overly Complex Pattern Matching
While pattern matching is powerful, overly complex patterns can be hard to understand:
# Too complex - hard to read and maintain
def process(%{
user: %{
name: name,
profile: %{
settings: %{
theme: theme,
notifications: %{
email: email_enabled
}
}
}
}
}) when email_enabled, do: "User #{name} with theme #{theme} has email notifications"
# Better - break down into smaller functions
def process(%{user: user}) do
with %{name: name, profile: profile} <- user,
%{settings: settings} <- profile,
%{theme: theme, notifications: notifications} <- settings,
%{email: true} <- notifications do
"User #{name} with theme #{theme} has email notifications"
else
_ -> "Invalid user structure or email notifications disabled"
end
end
Write Exhaustive Pattern Matches
Ensure that your pattern matches cover all possible input cases:
# Incomplete - missing cases
def handle_http_status(200), do: "OK"
def handle_http_status(404), do: "Not Found"
def handle_http_status(500), do: "Internal Server Error"
# What about other status codes?
# Complete - has a catch-all clause
def handle_http_status(200), do: "OK"
def handle_http_status(404), do: "Not Found"
def handle_http_status(500), do: "Internal Server Error"
def handle_http_status(code) when code >= 100 and code < 200, do: "Informational"
def handle_http_status(code) when code >= 200 and code < 300, do: "Success"
def handle_http_status(code) when code >= 300 and code < 400, do: "Redirection"
def handle_http_status(code) when code >= 400 and code < 500, do: "Client Error"
def handle_http_status(code) when code >= 500 and code < 600, do: "Server Error"
def handle_http_status(_), do: "Invalid HTTP status code"
Conclusion
Pattern matching in functions is one of Elixir's most powerful features, enabling you to write declarative, expressive code that handles different input patterns elegantly. By leveraging function clauses, destructuring, and guards, you can create code that is both concise and highly readable.
Key takeaways from this article include:
- Function clauses allow you to define multiple implementations of a function that match different patterns
- The order of function clauses matters, from specific to general
- Pattern matching works on all Elixir data types, including tuples, lists, maps, and binaries
- Guards extend pattern matching with additional conditions
- Destructuring allows you to extract values from complex data structures directly in function parameters
- Pattern matching provides an elegant alternative to traditional control flow constructs
- Common design patterns like state machines, builders, and strategies can be implemented cleanly with pattern matching
By mastering pattern matching in functions, you'll be able to write idiomatic Elixir code that is concise, expressive, and maintainable.
Tip: When using pattern matching in functions, start by identifying the different input patterns your function needs to handle, and then define a function clause for each case, moving from the most specific to the most general.
Further Reading
- Elixir Documentation - Pattern Matching
- Elixir School - Pattern Matching
- Programming Elixir by Dave Thomas (Chapters on pattern matching)
Next Steps
In the upcoming article, we'll explore the Pipe Operator in Elixir:
Pipe Operator
- Understanding the pipe operator (
|>
) and its benefits - Using the pipe operator to create clean, readable transformation pipelines
- Best practices for working with the pipe operator
- Common pitfalls and how to avoid them
- Advanced pipe operator techniques and patterns
- Combining the pipe operator with other Elixir features like pattern matching
The pipe operator is a powerful tool in Elixir that allows you to chain function calls together in a clear, readable way. It's one of the features that makes Elixir code so elegant and expressive, and we'll explore how to use it effectively in your applications.
Top comments (0)