← docs.speechwave.live

Lesson 1: pattern matching

The single concept that unlocks reading Elixir code

Open the public Speechwave repo alongside this lesson, or just follow the file links below. Every example comes from the real codebase, and by the end you'll be able to read the pattern matching in any file in the project.

Primary source: Elixir Getting Started: Pattern Matching (official guide, ~10 min read). Read it after this lesson to solidify.

The big shift: = is not assignment

In Ruby, = means "put this value in that box." In Elixir, = means "make the left side match the right side." This single difference changes how you read every line of code.

Ruby thinking: x = 42 → "Store 42 in x"
Elixir thinking: x = 42 → "Bind x so the left side matches the right side"

When the left side is just a variable, it looks the same as assignment. The difference shows up when the left side has structure:

{:ok, result} = {:ok, 42}
# result is now 42

{:ok, result} = {:error, "not found"}
# ** (MatchError), the atom :ok doesn't match :error
Key insight: Pattern matching is Elixir's primary control flow mechanism. Where Ruby uses if/else, Elixir often uses patterns that either match or don't.

Pattern 1: destructuring return values

The most common pattern in your codebase. Elixir functions return tagged tuples, {:ok, value} or {:error, reason}, and you match on the tag to handle each case.

lib/speechwave_web/live/dashboard_live.ex:47-67

case Talks.create_talk(scope, attrs) do
  {:ok, talk} ->
    # Success: talk is now bound to the created record
    url = talk_url(talk)
    # ...

  {:error, changeset} ->
    # Failure: changeset contains validation errors
    {:noreply, assign(socket, form: to_form(changeset))}
end
Ruby equivalent would be something like:
result = Talks.create_talk(scope, attrs)
if result.success?
  talk = result.value
  # ...
else
  changeset = result.error
  # ...
end

In Elixir, the destructuring and the branching happen in one step. The {:ok, talk} pattern both checks that it succeeded and extracts the talk in a single expression.

Pattern 2: multi-clause functions

In Ruby, you'd write one method with if statements inside. In Elixir, you write multiple definitions of the same function with different patterns. The runtime tries each clause top-to-bottom and uses the first one that matches.

lib/speechwave/talks.ex:74-82

# Clause 1: session already ended (ended_at is not nil)
def stop_session(%TalkSession{ended_at: ended_at} = session) when not is_nil(ended_at) do
  {:ok, session}
end

# Clause 2: session still active (catches everything else)
def stop_session(%TalkSession{} = session) do
  session
  |> TalkSession.changeset(%{ended_at: DateTime.utc_now() |> DateTime.truncate(:second)})
  |> Repo.update()
end

Let's unpack what's happening in that first clause:

Ruby equivalent:
def stop_session(session)
  return [:ok, session] if session.ended_at.present?

  session.update!(ended_at: Time.current)
  [:ok, session]
end

The Elixir version encodes the condition in the function signature. You know which case you're in before you read the body.

Pattern 3: matching map keys in function arguments

LiveView event handlers receive params as a map. Pattern matching pulls out exactly the keys you need, right in the function signature.

lib/speechwave_web/live/dashboard_live.ex:126

def handle_event("save_rename", %{"rename" => %{"label" => label}}, socket) do

This one line does three things:

  1. Matches only when the event name is "save_rename" (a string literal in the first argument)
  2. Reaches into the nested map to extract params["rename"]["label"] and binds it to label
  3. Binds the socket so it's available in the function body
Ruby equivalent:
def handle_event(event, params, socket)
  return unless event == "save_rename"
  label = params["rename"]["label"]
  # ...
end

Pattern 4: matching structs to enforce types

When you see %Talk{} in a function argument, it means "this function only accepts Talk structs." It's Elixir's way of doing type checking at the function boundary.

lib/speechwave/talks.ex:40

def delete_talk(%Talk{} = talk), do: Repo.delete(talk)

The %Talk{} pattern matches any Talk struct regardless of field values. The = talk binds the whole struct. If you pass a plain map or a different struct, you get a FunctionClauseError.

Pattern 5: the Scope pattern

Your codebase uses a consistent pattern to extract the current user from a scope:

lib/speechwave/talks.ex:26

def list_talks(%Scope{user: user}) do
  Repo.all(from t in Talk, where: t.user_id == ^user.id, order_by: [desc: t.inserted_at])
end

The %Scope{user: user} pattern does two things: it asserts the argument is a Scope struct, and it extracts the user field into a local variable. Every context function in Speechwave uses this pattern.

Pattern 6: ignoring values with _

The underscore matches anything and throws it away. You'll see it constantly.

lib/speechwave/application.ex:9

def start(_type, _args) do

This says: "I receive two arguments but don't need either." The leading underscore on a named variable (_type vs plain _) is a convention. It documents what the ignored value represents while telling the compiler you intentionally aren't using it.

lib/speechwave_web/live/dashboard_live.ex:30

def handle_params(_params, _uri, socket), do: {:noreply, socket}

Same idea: this LiveView callback is required but doesn't need the params or URI.

Pattern 7: pinning with ^

By default, variables on the left side of = get rebound to whatever matches. The pin operator ^ prevents this: it says "use this variable's current value and match against it instead of rebinding."

x = 1

x = 2       # x is rebound to 2 (no pin, this is normal)

^x = 3      # ** (MatchError), 2 doesn't match 3
^x = 2      # succeeds, 2 matches 2

This is most useful inside case and function heads, where you want to match against a value you already have:

expected_slug = "my-talk"

case Talks.get_talk_by_slug(slug) do
  %Talk{slug: ^expected_slug} ->
    # only matches if the talk's slug equals "my-talk"

  %Talk{} ->
    # matches any other talk
end

Without the ^, writing %Talk{slug: expected_slug} would rebind expected_slug to whatever the slug is, matching every talk, which isn't what you want.

Ruby parallel: Ruby doesn't have this problem because case/when always compares, never assigns. In Elixir, pattern matching does both. The pin operator is how you say "compare, don't assign."

You'll also see ^ in Ecto queries, where it injects an Elixir value as a SQL parameter, the same idea of "use this value, don't treat it as a new binding." That usage is covered in Lesson 5.


Practice: read the patterns

These quizzes use real code from Speechwave. Pick the answer that best describes what the pattern match does.

Question 1

What does this pattern match extract?

def handle_event("show_qr", %{"id" => id}, socket) do
Exactly. The string "show_qr" is a literal. It only matches that exact event. %{"id" => id} pulls the "id" key from the params map and binds its value to id. The rest of the map is ignored.
Not quite. String literals in patterns must match exactly. "show_qr" is not a variable, it's a filter. And %{"id" => id} extracts just the "id" key, not the whole map. Try again.

Question 2

What happens when this code runs?

{:ok, _} = Talks.delete_talk(talk)
Right. This is a deliberate crash-on-failure pattern. The :ok atom must match, so if delete_talk returns {:error, reason}, you get a MatchError. The _ discards the second element (the deleted struct). This is idiomatic: "I expect this to succeed; if it doesn't, something is deeply wrong."
Not quite. The underscore _ isn't a usable variable. It discards the value. And the :ok atom is a literal that must match. This line is asserting success. Try again.

Question 3

Given this function head:

def stop_session(%TalkSession{ended_at: ended_at} = session) when not is_nil(ended_at)

What kind of argument will this clause match?

Correct. Three things must be true: (1) the argument must be a %TalkSession{} struct, (2) its ended_at field is extracted and bound, (3) the guard when not is_nil(ended_at) further restricts to non-nil values. Plain maps won't match because the struct tag %TalkSession{} requires that specific struct type.
Close, but check the guard clause. The %TalkSession{} restricts to that struct type (not any map), and when not is_nil(ended_at) restricts to non-nil values. Try again.

Question 4

In this mount function, what does the first line inside the body do?

def mount(_params, _session, socket) do
  scope = socket.assigns.current_scope
  user = scope.user
Yes. Not everything is pattern matching! socket.assigns.current_scope is dot-access on a nested map/struct, similar to Ruby's hash access. The = here works like simple binding because the left side is just a bare variable. Recognizing when = is a simple bind vs. a structural match is part of reading Elixir fluently.
Look again. scope = socket.assigns.current_scope has a bare variable on the left. When the left side of = is just a variable, it's a simple bind (like assignment). The dot notation is map/struct field access, not method calls or destructuring. Try again.

Your next step

Open lib/speechwave/talks.ex and read through it from top to bottom. For every =, case, and function head, ask yourself: "What pattern is being matched here, and what gets bound?"

You should now be able to identify: