Hanso Group

Leveraging Pattern Matching in Elixir

Marasy Phi 15 minutes read

Pattern matching is often described as one of Elixir’s most elegant features. While many languages treat the equals sign as an assignment operator, in Elixir, it’s a match operator that enables powerful pattern matching capabilities. But beyond the basics lies a world of advanced techniques that can dramatically improve your code’s clarity, maintainability, and conciseness. In this article, we’ll explore these advanced pattern matching techniques and demonstrate how they can be applied to real-world scenarios.

Pattern Matching Fundamentals

Before diving into advanced techniques, let’s briefly review the fundamentals of pattern matching in Elixir.

In its simplest form, pattern matching lets you destructure data:

## Basic matching
{a, b, c} = {:hello, "world", 42}
## a = :hello, b = "world", c = 42

## List matching
[head | tail] = [1, 2, 3, 4]
## head = 1, tail = [2, 3, 4]

## Map matching
%{name: name, age: age} = %{name: "Jane", age: 28, occupation: "Developer"}
## name = "Jane", age = 28

These examples demonstrate the power of pattern matching for extracting values from complex data structures. But pattern matching goes far beyond these simple cases.

Advanced Pattern Matching Techniques

Matching in Function Heads

One of the most powerful applications of pattern matching is in function definitions. By using pattern matching in function heads, you can create elegant, declarative code that handles different cases:

defmodule RecursiveSum do
  # Base case: empty list
  def sum([]), do: 0

  # Recursive case: non-empty list
  def sum([head | tail]), do: head + sum(tail)
end

This example demonstrates how pattern matching enables a clean implementation of recursion without explicit conditional logic.

Guards for Extended Matching

Guards extend pattern matching by adding extra conditions that must be satisfied:

defmodule Math do
  def factorial(n) when n < 0, do: {:error, "Cannot calculate factorial of negative number"}
  def factorial(0), do: 1
  def factorial(n) when is_integer(n), do: n * factorial(n - 1)
  def factorial(_), do: {:error, "Input must be a non-negative integer"}
end

Guards allow you to express complex conditions directly in function headers, making your code more declarative and easier to reason about.

Matching Binary Data

Pattern matching extends to binary data, enabling powerful parsing capabilities:

defmodule ImageParser do
  def parse_header(<<0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, rest::binary>>), do: {:png, rest}
  def parse_header(<<0xFF, 0xD8, rest::binary>>), do: {:jpeg, rest}
  def parse_header(<<0x47, 0x49, 0x46, 0x38, rest::binary>>), do: {:gif, rest}
  def parse_header(_), do: {:unknown, nil}
end

This example demonstrates how to parse binary file headers without complex conditionals or multiple passes through the data.

Pin Operator for Matching Against Variables

The pin operator (^) allows you to match against the value of a variable rather than rebinding the variable:

expected_status = :success

def handle_response({^expected_status, data}), do: {:ok, data}
def handle_response({:error, reason}), do: {:error, reason}
def handle_response(_), do: {:error, :unknown}

The pin operator is particularly useful when you want to match against a specific value rather than capture any value.

Real-World Applications

Let’s explore some real-world applications of these advanced pattern matching techniques.

Building a Parser for a Custom Protocol

Imagine we’re building a parser for a custom network protocol. Pattern matching makes this task elegant and straightforward:

defmodule ProtocolParser do
  def parse_packet(<<
    version::8,
    type::8,
    sequence_number::16,
    payload_length::32,
    payload::binary-size(payload_length),
    rest::binary
  >>) do
    packet = %{
      version: version,
      type: decode_type(type),
      sequence_number: sequence_number,
      payload: payload
    }

    {packet, rest}
  end

  def parse_packet(incomplete_data), do: {:incomplete, incomplete_data}

  defp decode_type(0x01), do: :heartbeat
  defp decode_type(0x02), do: :data
  defp decode_type(0x03), do: :ack
  defp decode_type(_), do: :unknown
end

This parser handles both complete and incomplete packets elegantly. By using binary pattern matching, we can extract structured data directly from binary input.

Implementing a State Machine

Pattern matching is perfect for implementing state machines, making the transitions between states explicit and clear:

defmodule OrderProcessing do
  def transition({:cart, items}, :checkout, payment_info) do
    {:pending_payment, items, payment_info}
  end

  def transition({:pending_payment, items, payment_info}, :payment_received, transaction_id) do
    {:paid, items, payment_info, transaction_id}
  end

  def transition({:paid, items, payment_info, transaction_id}, :ship, tracking_number) do
    {:shipped, items, payment_info, transaction_id, tracking_number}
  end

  def transition({:shipped, items, payment_info, transaction_id, tracking_number}, :deliver, nil) do
    {:delivered, items, payment_info, transaction_id, tracking_number}
  end

  def transition(state, action, _data) do
    {:error, "Invalid transition: #{inspect(state)} cannot perform #{inspect(action)}"}
  end
end

This state machine clearly defines the valid transitions between states, with the final catch-all function handling invalid transitions.

Building a Recursive Validator

Pattern matching makes recursive validation elegant:

defmodule NestedValidator do
  def validate(%{children: children} = node) when is_list(children) do
    valid_children = Enum.map(children, &validate/1)
    invalid_children = Enum.filter(valid_children, fn {status, _} -> status == :error end)

    if Enum.empty?(invalid_children) do
      {:ok, Map.put(node, :children, Enum.map(valid_children, fn {:ok, child} -> child end))}
    else
      {:error, "Invalid children: #{inspect(invalid_children)}"}
    end
  end

  def validate(%{value: value} = node) when is_integer(value) and value > 0 do
    {:ok, node}
  end

  def validate(%{value: value} = _node) do
    {:error, "Invalid value: #{inspect(value)}. Must be a positive integer."}
  end

  def validate(invalid) do
    {:error, "Invalid node structure: #{inspect(invalid)}"}
  end
end

This validator recursively validates a tree structure where each node must either have a positive integer value or valid children.

Advanced Pattern Matching in Practice

Working with Deeply Nested Data

When working with deeply nested data structures, pattern matching enables elegant access without traversing multiple levels manually:

def extract_config(%{
  settings: %{
    database: %{
      connection: %{
        host: host,
        port: port
      }
    }
  }
}) do
  "#{host}:#{port}"
end

def extract_config(_), do: "localhost:5432" # Default

This function extracts database connection details from a deeply nested configuration structure, with a fallback for when the expected structure isn’t present.

Pattern Matching with Maps and Optional Keys

Maps pose an interesting challenge because they match as long as all specified keys are present. For optional keys, you can use nested patterns:

def process_user(%{name: name, preferences: %{theme: theme}} = user) do
  # User with name and theme preference
  theme_specific_processing(user, theme)
end

def process_user(%{name: name, preferences: %{}} = user) do
  # User with preferences but no theme
  process_with_default_theme(user)
end

def process_user(%{name: name} = user) do
  # User with no preferences
  create_default_preferences(user)
end

This approach handles different variations of user data elegantly.

Leveraging Multi-clause Functions for Pipeline Processing

Pattern matching excels in data processing pipelines. Instead of deep conditional nesting, you can use multi-clause functions:

defmodule DataPipeline do
  def process(data) do
    data
    |> parse()
    |> validate()
    |> transform()
    |> format()
  end

  # Parse stage
  defp parse({:raw, binary}) when is_binary(binary), do: {:parsed, Jason.decode!(binary)}
  defp parse({:raw, non_binary}), do: {:error, "Expected binary data, got: #{inspect(non_binary)}"}
  defp parse(already_parsed), do: already_parsed

  # Validate stage
  defp validate({:parsed, %{"type" => type, "data" => data}}) when type in ["user", "order", "product"], do: {:validated, type, data}
  defp validate({:parsed, invalid}), do: {:error, "Invalid data structure: #{inspect(invalid)}"}
  defp validate({:error, _} = error), do: error

  # Transform stage
  defp transform({:validated, "user", data}), do: {:transformed, User.from_json(data)}
  defp transform({:validated, "order", data}), do: {:transformed, Order.from_json(data)}
  defp transform({:validated, "product", data}), do: {:transformed, Product.from_json(data)}
  defp transform({:error, _} = error), do: error

  # Format stage
  defp format({:transformed, entity}), do: {:success, entity}
  defp format({:error, _} = error), do: error
end

This pipeline gracefully handles errors at any stage and passes successful results through each transformation.

Pattern Matching with Processes and Messages

Pattern matching is particularly powerful when working with Elixir’s processes and message passing:

defmodule Worker do
  use GenServer

  # ... init and other callbacks ...

  @impl true
  def handle_call({:process, data}, _from, state) when is_list(data) do
    result = Enum.map(data, &process_item/1)
    {:reply, {:ok, result}, state}
  end

  @impl true
  def handle_call({:process, data}, _from, state) when is_map(data) do
    result = Map.new(data, fn {k, v} -> {k, process_item(v)} end)
    {:reply, {:ok, result}, state}
  end

  @impl true
  def handle_call({:process, _invalid_data}, _from, state) do
    {:reply, {:error, :invalid_data_type}, state}
  end

  @impl true
  def handle_cast({:configure, %{concurrency: n}}, state) when is_integer(n) and n > 0 do
    {:noreply, %{state | concurrency: n}}
  end

  @impl true
  def handle_cast({:configure, _invalid_config}, state) do
    # Invalid configuration, ignore
    {:noreply, state}
  end

  @impl true
  def handle_info(:timeout, state) do
    # Handle timeout
    {:noreply, state}
  end

  @impl true
  def handle_info(_unknown_message, state) do
    # Ignore unknown messages
    {:noreply, state}
  end
end

This GenServer implementation uses pattern matching extensively to handle different types of requests and messages, resulting in clean, maintainable code.

Performance Considerations

While pattern matching is elegant, it’s important to understand its performance implications:

  1. Compilation Optimization: The Elixir compiler optimizes pattern matching, so most basic matches have negligible performance impact.

  2. Order Matters: Pattern matching clauses are evaluated in order, so put the most common cases first for optimal performance.

  3. Guard Complexity: Complex guards can impact performance. Consider breaking extremely complex functions into smaller, more focused functions.

  4. Binary Matching: Matching large binaries can be memory-intensive if not done carefully. Use binary streaming for large files.

Here’s an example of optimizing a binary parser for large files:

defmodule StreamingParser do
  def parse_large_file(file_path) do
    File.stream!(file_path, [], 1024)
    |> Stream.transform(<<>>, &process_chunk/2)
    |> Enum.to_list()
  end

  defp process_chunk(chunk, buffer) do
    new_buffer = <<buffer::binary, chunk::binary>>
    {records, remaining} = extract_records(new_buffer, [])
    {records, remaining}
  end

  defp extract_records(<<>>, acc), do: {Enum.reverse(acc), <<>>}

  defp extract_records(<<
    length::32,
    record::binary-size(length),
    rest::binary
  >>, acc) when byte_size(record) == length do
    extract_records(rest, [parse_record(record) | acc])
  end

  defp extract_records(incomplete, acc), do: {Enum.reverse(acc), incomplete}

  defp parse_record(binary) do
    # Actual record parsing logic
    binary
  end
end

This approach processes large files in chunks, maintaining a buffer between chunks to handle records that span chunk boundaries.

Best Practices for Pattern Matching

To make the most of Elixir’s pattern matching capabilities, follow these best practices:

  1. Be Explicit: Make pattern matches as explicit as possible to clearly communicate your intent.

  2. Use Guards Judiciously: Guards are powerful but should be used for simple conditions. Move complex logic into function bodies.

  3. Leverage Multi-clause Functions: Use multiple function clauses instead of conditional expressions inside functions.

  4. Mind the Order: Remember that pattern matching clauses are evaluated in order, so more specific patterns should come before more general ones.

  5. Understand Match Failures: Pattern match failures raise an error unless wrapped in a control flow structure like case.

  6. Use Underscore for Unused Variables: If you’re not using a variable, prefix it with an underscore to avoid compiler warnings.

  7. Combine with Pipelines: Pattern matching complements pipeline-oriented programming, leading to clean, readable code.

Conclusion

Pattern matching is a cornerstone of Elixir programming, enabling elegant, concise, and expressive code. By mastering advanced pattern matching techniques, you can write code that is not only more maintainable but also more efficient and robust.

The examples in this article demonstrate how pattern matching can be applied to solve complex problems across various domains. From parsing binary protocols to implementing state machines and handling nested data structures, pattern matching provides a powerful tool that encourages declarative, functional programming styles.

As you continue to develop your Elixir skills, look for opportunities to leverage pattern matching in your code. You’ll likely find that many complex problems become simpler when approached with pattern matching in mind.

References

  1. Thomas, D. (2018). Programming Elixir 1.6: Functional, Concurrent, Pragmatic, Fun. The Pragmatic Bookshelf.

  2. Valim, J. & McCord, C. (2014). Metaprogramming Elixir: Write Less Code, Get More Done. The Pragmatic Bookshelf.

  3. Elixir Documentation: Pattern Matching. https://elixir-lang.org/getting-started/pattern-matching.html

  4. Tate, B. A. (2014). Seven More Languages in Seven Weeks: Languages That Are Shaping the Future. The Pragmatic Bookshelf.

  5. Marx, B. (2018). Adopting Elixir: From Concept to Production. The Pragmatic Bookshelf.

Back to all articles