Pattern Matching with Case

Pattern Matching in 'case' Statements

Welcome back. In the last lesson, you added guard clauses to function heads. As a reminder, guards let you attach extra conditions to a match. Today, you will use the same ideas inside a case expression. This gives you clean, readable branching based on data shape at runtime.

Classifying Users with 'case'

defmodule UserProcessor do
  def process_user(user) do
    case user do
      %{name: name, age: age, role: :admin} ->
        "Admin #{name} is #{age} years old"
      
      %{name: name, age: age} when age >= 18 ->
        "Adult user #{name}"
      
      %{name: name, age: age} ->
        "Minor user #{name} is #{age} years old"
      
      _ ->
        "Invalid user data"
    end
  end
end

Explanation:

  • case user do tries each clause from top to bottom and picks the first match.
  • %{name: name, age: age, role: :admin} matches maps that have name, age, and role equal to :admin. It binds name and age and formats the admin message.
  • %{name: name, age: age} when age >= 18 matches any map with name and age where the guard confirms adulthood. This is a reminder of guards, now used inside case.
  • %{name: name, age: age} (no guard) catches the remaining valid user maps and labels them as minors.
  • _ is the catch‑all for anything that doesn’t fit the expected shape. Order matters: more specific patterns go first.

Why this design? You keep logic declarative: match the shape of the input (a map with specific keys) and refine with guards only where needed.

Map Subset Matching and Why Ordering is Crucial

  • Map patterns match subsets: a pattern like %{name: name, age: age} will match any map that has at least those keys, even if it has extra keys (email, id, etc.). This also matches structs that define those fields (see below).
  • Because subset patterns can overlap, put more specific clauses first. For example, a struct clause or a clause that matches a specific role should appear before a broader “has name and age” clause, otherwise the broader clause will grab the input and the specific one will never run.

Example of overlap and ordering:

defmodule User do
  defstruct [:name, :age, :role]
end

defmodule UserKinds do
  def describe(input) do
    case input do
      %User{name: name, age: age} ->
        "Struct User #{name} (#{age})"

      # This map pattern would also match %User{} if it came first.
      %{name: name, age: age} ->
        "Plain map user #{name} (#{age})"
    end
  end
end

If you switch the two clauses above, the second one will never run because the map subset clause will match both maps and structs.

IO.puts(UserProcessor.process_user(%{name: "Alice", age: 30, role: :admin}))
IO.puts(UserProcessor.process_user(%{name: "Bob", age: 25}))
IO.puts(UserProcessor.process_user(%{name: "Charlie", age: 15}))

What happens:

  • "Alice" matches the first clause (role: :admin) -> "Admin Alice is 30 years old".
  • "Bob" matches the second clause (has name and age; guard age >= 18) -> "Adult user Bob".
  • "Charlie" matches the third clause (has name and age; no guard) -> "Minor user Charlie is 15 years old".

This mirrors real systems where you branch by user role and age using clear, pattern-driven logic.

Grouping Variants with Guards

You can consolidate related variants with guards:

defmodule GroupedRoles do
  def label(user) do
    case user do
      %{name: name, role: role} when role in [:admin, :owner] ->
        "Privileged: #{name}"

      %{name: name, role: :moderator} ->
        "Moderator: #{name}"

      %{name: name} ->
        "Regular user: #{name}"
    end
  end
end

Notes on guards here:

  • in in guards supports lists and ranges on the right side (e.g., role in [:admin, :owner] or n in 1..10). You can also combine conditions with and/or.
  • Guard expressions are restricted. Pure operators and a limited set of functions (like is_integer/1, is_map/1, byte_size/1) are allowed. Avoid calling arbitrary functions in guards.

Matching Other Data Shapes Inside 'case'

  • Struct vs bare map
defmodule StructVsMap do
  def classify(input) do
    case input do
      %User{name: name, age: age} ->
        "Struct user #{name} (#{age})"

      %{name: name, age: age} ->
        "Map user #{name} (#{age})"

      _ ->
        "Unknown"
    end
  end
end
  • Tagged tuples (common for success/error)
defmodule TupleResults do
  def handle(result) do
    case result do
      {:ok, %User{name: name}} ->
        "Found #{name}"

      {:error, :not_found} ->
        "User not found"

      {:error, reason} when reason in [:timeout, :rate_limited] ->
        "Temporary error: #{inspect(reason)}"

      {:error, reason} ->
        "Permanent error: #{inspect(reason)}"
    end
  end
end
  • Binaries/strings (prefix/suffix, sizes)
defmodule BinaryMatch do
  # Extract a Bearer token
  def bearer(token) do
    case token do
      "Bearer " <> jwt when byte_size(jwt) > 0 -> {:ok, jwt}
      _ -> :error
    end
  end

  # Match a 3-letter prefix + rest
  def prefix(<<pfx::binary-size(3), rest::binary>>) do
    case pfx do
      "img" -> {:image, rest}
      "vid" -> {:video, rest}
      _ -> {:other, pfx <> rest}
    end
  end
end
  • Lists (head/tail)
defmodule ListMatch do
  def first_role(roles) do
    case roles do
      [first | _rest] -> {:ok, first}
      [] -> :none
    end
  end
end

Variables: Scoping and Rebinding in 'case'

  • Variables introduced inside a clause are local to that clause. To use a new variable after the case, you must bind it in every clause.
  • Existing variables may be rebound inside a clause; the rebinding persists after the case for the clause that ran. Use this intentionally.

Examples:

# New variable bound in all clauses -> available after case
status =
  case System.get_env("ENV") do
    "prod" -> :locked
    _ -> :open
  end
# status is usable here

# New variable only in one clause -> not available after case
case :ok do
  :ok -> only_here = 1
  :error -> :noop
end
# only_here is not defined outside (will fail if you try to use it)

# Rebinding an existing variable intentionally
role = :user
role =
  case %{role: :admin} do
    %{role: :admin} -> :admin
    _ -> role
  end
# role is now :admin

Failure Modes When No Clause Matches

If no clause matches and you don’t provide a catch‑all (_), Elixir raises a CaseClauseError at runtime:

defmodule Fragile do
  def only_zero(n) do
    case n do
      0 -> :zero
      # No other clause or catch-all provided
    end
  end
end

# Fragile.only_zero(1) -> ** (CaseClauseError) no case clause matching: 1

Add a final _ -> ... clause when unknown or evolving inputs are possible.

Choosing Between Matching Methods

  • Multiple function heads:
    • Prefer when branching solely on the shape/guards of function arguments at the API boundary.
    • Clear, performant dispatch; keeps callers simple.
  • case:
    • Prefer when you need to match on a value computed at runtime (result of a function, nested field, intermediate pipeline value).
    • Good inside a function body to keep branching local.
  • cond:
    • Prefer when you’re checking unrelated boolean conditions rather than data shape (e.g., multiple range checks, feature flags).
    • Readable for pure predicate chains; no pattern matching.
  • with:
    • Prefer for sequencing multiple matches that can fail (e.g., {:ok, _} pipelines), short-circuiting on the first mismatch.
    • Reduces nested case expressions; keep happy-path linear.

Rule of thumb: push branching to function heads when it’s about top‑level input shape; use case for runtime values or when mixing shapes; use cond for predicate-only flows; use with to linearize chained matches.

Summary and Next Steps

You used case to:

  • Match map shapes and bind fields in place, including nested/complex patterns (structs, tuples, binaries, lists).
  • Apply guards in a case clause to refine a match, including grouping variants with in and combining conditions.
  • Understand map subset matching and why clause ordering is crucial, especially with overlapping patterns and structs.
  • Handle failure explicitly; without a matching clause, case raises CaseClauseError, so include a catch‑all when appropriate.
  • Work with variable scope: new variables don’t escape a clause unless bound in all clauses; existing variables can be rebound intentionally.
  • See pinned matches (^var) inside case to compare against an existing value (you’ll explore pinning more in the next unit).

This approach keeps branching logic readable and robust without nested ifs while giving you precise control over data shapes at runtime.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal