DEV Community

Cover image for Learning Elixir: Pattern Matching in Functions
João Paulo Abreu
João Paulo Abreu

Posted on

3

Learning Elixir: Pattern Matching in Functions

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

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> Greeting.say_hello("")
"Hello, stranger!"

iex> Greeting.say_hello("Alice")
"Hello, Alice!"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> OrderExample.process(0)
"Caught everything: 0"

iex> OrderExample.process(42)
"Caught everything: 42"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> OrderExample.process(0)
"Zero"

iex> OrderExample.process(42)
"Caught everything: 42"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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: @"}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>>}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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\"}"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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

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.

DevCycle image

Ship Faster, Stay Flexible.

DevCycle is the first feature flag platform with OpenFeature built-in to every open source SDK, designed to help developers ship faster while avoiding vendor-lock in.

Start shipping

Top comments (0)

DevCycle image

Ship Faster, Stay Flexible.

DevCycle is the first feature flag platform with OpenFeature built-in to every open source SDK, designed to help developers ship faster while avoiding vendor-lock in.

Start shipping