Elixir: Pattern Matching

In Elixir, = is not assignment — it is a match operator. The left side is a pattern; Elixir tries to make it equal the right side, binding variables as needed. This one idea powers destructuring, branching, function dispatch, and even binary parsing.

The Match Operator

x = 1                 # binds x to 1
1 = x                 # matches! 1 is not a variable, so it must equal x
2 = x                 # ** (MatchError) — no way to make 2 equal 1

{x, y} = {1, 2}       # destructures a tuple: x = 1, y = 2
[a, b, c] = [1, 2, 3] # destructures a list
{:ok, value} = {:ok, 42}  # matches the tagged-tuple convention

# A variable already bound can be re-bound, or *pinned* to require equality.
y = 99                # re-binds y (the default)
^y = 100              # ** (MatchError) — ^y means "must equal current y"

When a match fails, Elixir raises a MatchError. In production code you usually match inside case or function heads, where failure selects the next clause instead of crashing.

Destructuring Maps and Structs

user = %{name: "Ada", role: :admin, visits: 12}

%{name: name} = user          # partial match: only requires the :name key
%{role: role} = user          # role = :admin

# Structs match on their module (see the polymorphism lesson).
defmodule Report do
  defstruct title: "", rows: 0
end

%Report{rows: rows} = %Report{title: "Q3", rows: 7}
rows                          #=> 7

Guards

Guards are extra conditions attached to a pattern. They use a restricted set of functions that the runtime guarantees to be side-effect free, so matching stays a pure, observable operation.

defmodule Access do
  # Clauses are tried top to bottom; guards narrow each one.
  def open(%{age: age}) when age >= 18, do: :granted
  def open(%{age: age}) when age > 0 and age < 18, do: :denied
  def open(_), do: {:error, :invalid}   # catch-all: bad or missing data
end

Function Heads

Multiple clauses with pattern heads are Elixir's switch and its loop structure at the same time: the runtime tries clauses top to bottom, and the first head that matches runs. Recursion plus a base clause is the classic functional loop.

defmodule Math do
  # Base clause: recursion stops at 0 — clause ORDER matters.
  def sum_to(0), do: 0
  def sum_to(n) when n > 0, do: n + sum_to(n - 1)

  # Dispatching on message shape instead of if/else chains:
  def handle({:add, a, b}), do: {:ok, a + b}
  def handle({:div, _, 0}), do: {:error, :division_by_zero}
  def handle({:div, a, b}), do: {:ok, a / b}
  def handle(other), do: {:error, {:unknown, other}}
end

case, cond and with

Three branching forms, three situations. Use case to match a value, cond to test conditions (the equivalent of if/else-if chains), and with to chain matches and fail fast with a custom else.

A value enters case, is tried against three patterns, and the first matching branch produces the result
Fig. 1 — Clauses are tried in order; the first pattern that matches decides the branch and binds its variables.
# case: match on the shape of one value
case File.read("config.txt") do
  {:ok, body}    -> parse(body)
  {:error, :enoent} -> "missing file"       # match a SPECIFIC error
  {:error, reason}  -> {:error, reason}     # bind any other reason
end

# cond: first truthy condition wins
cond do
  temperature > 38 -> :fever
  temperature > 37 -> :warm
  true -> :normal          # `true` acts as the else
end

# with: chain happy-path matches; the first non-match jumps to `else`
with {:ok, body} <- File.read("users.json"),
     {:ok, users} <- JSON.decode(body) do
  {:ok, length(users)}
else
  # `else` re-matches whatever failed — often the raw error tuple
  {:error, reason} -> {:error, reason}
end

Tagged Tuples, the Convention

Elixir functions that can fail return {:ok, value} or {:error, reason} instead of raising. Matching forces the caller to acknowledge both paths — the compiler in your head. Exceptions are reserved for genuinely unexpected, "should never happen" states, where crashing and letting a supervisor restart the component is the right answer.

Common Pitfalls

  • Underscore vs pinned variable: _ discards; _role binds but marks unused; ^role requires equality with an existing binding.
  • Clause order: a too-general head first (def f(_)) silently shadows later specific clauses.
  • Map patterns are partial: %{name: n} = user succeeds even if user has ten other keys — that is a feature, not a bug.
  • with without else returns the non-matching value verbatim — sometimes useful, often confusing; add the else.

Practice

  1. Write classify/1 that accepts {:ok, n} (positive, zero, negative via guards), {:error, reason}, and anything else.
  2. Implement sum_to/1, then explain to yourself why the base clause must come first.
  3. Convert a nested case into with and compare the error handling.

Next: Functions & Recursion