Powered by AppSignal & Oban Pro

Pattern Matching & Guards

01-pattern-matching.livemd

Pattern Matching & Guards

Learning Objectives

By the end of this checkpoint, you will:

  • Master pattern matching on tuples, lists, and maps
  • Understand when to use guards vs pattern matching
  • Write multiple function heads with different patterns
  • Know the difference between = (match) and == (equality)

Setup

Mix.install([])

Concept: Pattern Matching

Pattern matching is one of Elixir’s most powerful features. Unlike assignment in other languages, the = operator in Elixir is a match operator that attempts to make the left side equal to the right side.

# Simple pattern match
{:ok, value} = {:ok, 42}
IO.puts("Matched value: #{value}")

# List pattern matching
[first | rest] = [1, 2, 3, 4]
IO.puts("First: #{first}")
IO.inspect(rest, label: "Rest")

# Map pattern matching
%{name: user_name, age: user_age, city: user_city} = %{name: "Alice", age: 30, city: "NYC"}
IO.puts("Name: #{user_name}, Age: #{user_age}")
IO.puts("City: #{user_city}")

:ok

Concept: Guards

Guards are additional constraints you can add to function clauses. They allow you to pattern match AND check conditions.

defmodule AgeChecker do
  def adult?(age) when is_integer(age) and age >= 18 do
    true
  end

  def adult?(_), do: false
end

IO.puts("Is 25 an adult? #{AgeChecker.adult?(25)}")
IO.puts("Is 15 an adult? #{AgeChecker.adult?(15)}")
IO.puts("Is 'hello' an adult? #{AgeChecker.adult?("hello")}")

Interactive Exercise 1.1: Complete the Pattern Match

Fill in the blanks to make these pattern matches work:

# Match on a tuple
# TODO: Fill in the blank
{:ok, result_value} = {:ok, 42}
IO.puts("Result: #{result_value}")

# Match on a list head and tail
# TODO: Fill in the blank
[first_item | remaining_items] = [1, 2, 3, 4]
IO.puts("First: #{first_item}")
IO.inspect(remaining_items, label: "Rest")

# Match on a map with specific keys
# TODO: Fill in the blanks
%{name: person_name, age: person_age} = %{name: "Alice", age: 30}
IO.puts("Name: #{person_name}, Age: #{person_age}")

:ok

Interactive Exercise 1.2: Fix the Guards

This code has bugs. Fix the guard clauses to handle edge cases properly:

defmodule SafeMath do
  # Bug: Guard allows zero division
  # TODO: Add guard to prevent division by zero
  def safe_div(a, b) when is_number(a) and is_number(b) and b != 0 do
    {:ok, a / b}
  end

  def safe_div(_, 0), do: {:error, :zero_division}
  def safe_div(_, _), do: {:error, :invalid_input}

  # Bug: Missing guard for negative numbers
  # TODO: Add guards for negative numbers
  def sqrt(n) when is_number(n) and n >= 0 do
    {:ok, :math.sqrt(n)}
  end

  def sqrt(n) when is_number(n) and n < 0 do
    {:error, :negative_number}
  end

  def sqrt(_), do: {:error, :invalid_input}
end

# Test the functions
IO.inspect(SafeMath.safe_div(10, 2), label: "10 / 2")
IO.inspect(SafeMath.safe_div(10, 0), label: "10 / 0")
IO.inspect(SafeMath.sqrt(16), label: "sqrt(16)")
IO.inspect(SafeMath.sqrt(-4), label: "sqrt(-4)")

Interactive Exercise 1.3: Pattern Match Challenge

Write a function that matches on different result tuples and provides default values:

defmodule ResultHandler do
  @doc """
  Unwraps results or provides default values.

  ## Examples
      iex> ResultHandler.unwrap({:ok, 42}, 0)
      42

      iex> ResultHandler.unwrap({:error, :not_found}, 0)
      0

      iex> ResultHandler.unwrap(nil, 0)
      0
  """
  # TODO: Implement using pattern matching
  def unwrap({:ok, value}, _default), do: value
  def unwrap({:error, _reason}, default), do: default
  def unwrap(nil, default), do: default
  def unwrap(_other, default), do: default
end

# Test your implementation
IO.puts("Test 1: #{ResultHandler.unwrap({:ok, 42}, 0)}")
IO.puts("Test 2: #{ResultHandler.unwrap({:error, :not_found}, 0)}")
IO.puts("Test 3: #{ResultHandler.unwrap(nil, 99)}")
IO.puts("Test 4: #{ResultHandler.unwrap("invalid", -1)}")

Advanced Pattern Matching

Let’s explore more complex patterns:

defmodule PatternExamples do
  # Matching on specific values
  def handle_response({:ok, %{status: 200, body: body}}) do
    {:success, body}
  end

  def handle_response({:ok, %{status: status}}) when status >= 400 do
    {:error, :http_error}
  end

  def handle_response({:error, reason}) do
    {:error, reason}
  end

  # Pin operator - match against existing variable
  def find_in_list(list, target) do
    case list do
      [^target | _] -> {:found, :first}
      [_, ^target | _] -> {:found, :second}
      _ -> :not_found
    end
  end
end

# Test the functions
response1 = {:ok, %{status: 200, body: "Success"}}
IO.inspect(PatternExamples.handle_response(response1), label: "Response 1")

response2 = {:ok, %{status: 404}}
IO.inspect(PatternExamples.handle_response(response2), label: "Response 2")

# Pin operator example
IO.inspect(PatternExamples.find_in_list([1, 2, 3], 1), label: "Find 1 in list")
IO.inspect(PatternExamples.find_in_list([1, 2, 3], 2), label: "Find 2 in list")

Self-Assessment

Use this interactive checklist to track your understanding:

form = Kino.Control.form(
  [
    match_tuples: {:checkbox, "I can match on tuples, lists, and maps"},
    guards_vs_patterns: {:checkbox, "I understand when to use guards vs pattern matching"},
    multiple_heads: {:checkbox, "I can write multiple function heads with different patterns"},
    match_vs_equality: {:checkbox, "I know the difference between = (match) and == (equality)"},
    pin_operator: {:checkbox, "I understand the pin operator (^)"}
  ],
  submit: "Check Progress"
)

Kino.render(form)

Kino.listen(form, fn event ->
  completed = event.data |> Map.values() |> Enum.count(&amp; &amp;1)
  total = map_size(event.data)

  progress_message =
    if completed == total do
      "🎉 Excellent! You've mastered Checkpoint 1!"
    else
      "Keep going! #{completed}/#{total} objectives complete"
    end

  Kino.Markdown.new("### Progress: #{progress_message}") |> Kino.render()
end)

Practice Challenge

Try implementing a function that uses pattern matching to parse different command formats:

defmodule CommandParser do
  def parse("help"), do: {:help, :general}
  def parse("help " <> topic), do: {:help, topic}
  def parse("exit"), do: {:exit, :normal}
  def parse("quit"), do: {:exit, :normal}
  def parse("echo " <> message), do: {:echo, message}
  def parse(cmd), do: {:unknown, cmd}
end

# Test the parser
commands = ["help", "help patterns", "exit", "echo Hello World", "invalid"]

for cmd <- commands do
  result = CommandParser.parse(cmd)
  IO.inspect(result, label: "Command: #{cmd}")
end

:ok

Key Takeaways

  • Pattern matching is used for destructuring and binding values
  • Guards add additional constraints beyond structure
  • Multiple function clauses allow you to handle different cases cleanly
  • The pin operator ^ matches against an existing variable’s value
  • Pattern matching happens at compile time when possible (more efficient!)

Next Steps

Congratulations on completing Checkpoint 1! Continue to the next checkpoint:

Continue to Checkpoint 2: Recursion & Tail-Call Optimization →

Or return to the Setup Guide to explore other phases.