← docs.speechwave.live

Lesson 2: the pipe operator & data transformation

How Elixir replaces method chaining with something more powerful

Primary source: Elixir School: Pipe Operator (5 min). For the Enum module: hexdocs.pm/elixir/Enum.

The core idea

In Ruby, data transformations chain off the object: "Hello".downcase.strip.gsub(...). The object carries the data, and each method returns a new object you keep calling methods on.

Elixir has no objects. Data and functions are separate. The pipe operator |> is the glue. It takes the result of the left side and passes it as the first argument to the function on the right.

# These two are identical:
String.trim(String.downcase(email))

email |> String.downcase() |> String.trim()

Without pipes, you'd nest function calls inside-out (hard to read). With pipes, you read top-to-bottom, left-to-right, and the data flows through a pipeline of transformations.

Key insight: The pipe always inserts into the first argument position. x |> f(a, b) becomes f(x, a, b). This is why Elixir libraries consistently put the "main data" as their first parameter, which makes everything pipe-friendly.

Three shapes you'll see in Speechwave

Shape 1: string / data transformation

The simplest pipe chains: pure data in, transformed data out.

lib/speechwave/talks.ex:141-146

def generate_slug(title) do
  title
  |> String.downcase()
  |> String.replace(~r/[^a-z0-9\s]/, "")
  |> String.trim()
  |> String.replace(~r/\s+/, "-")
end

Read this as a recipe: "Take the title, lowercase it, strip non-alphanumeric characters, trim whitespace, replace spaces with hyphens." Each step receives the result of the previous step as its first argument.

Ruby equivalent:
def generate_slug(title)
  title
    .downcase
    .gsub(/[^a-z0-9\s]/, "")
    .strip
    .gsub(/\s+/, "-")
end

Almost identical readability. The difference: Ruby calls methods on the string object. Elixir passes the string into free functions. This matters when you want to pipe through functions from different modules. In Elixir, everything pipes naturally.

Another example: generating an API key.

lib/speechwave/accounts.ex:53

new_key = :crypto.strong_rand_bytes(byte_length) |> Base.encode16(case: :lower)

This pipes across two completely different modules (:crypto from Erlang, Base from Elixir). In Ruby, you'd need to wrap the result: Base.encode16(crypto_output). Here, the pipe handles it seamlessly.

Shape 2: the changeset pipeline

This is the most common pipe pattern in Phoenix apps. You'll see it dozens of times in Speechwave:

lib/speechwave/accounts.ex:174-177

def register_user(attrs) do
  %User{}
  |> User.email_changeset(attrs)
  |> Repo.insert()
end

Read it as: "Start with an empty User struct, build a changeset with validations, then insert into the database." The data flows: struct โ†’ changeset โ†’ database result.

A longer version with more steps:

lib/speechwave/accounts.ex:55-57

user
|> Ecto.Changeset.change(api_key: new_key)
|> Repo.update()

And from Talks:

lib/speechwave/talks.ex:35-37

%Talk{user_id: user.id}
|> Talk.changeset(attrs)
|> Repo.insert()
Key insight: The changeset pipeline is Elixir's replacement for ActiveRecord. Instead of user.update!(api_key: new_key), you build a data structure describing the change (the changeset), then explicitly hand it to the database layer. This separation is why Ecto validations compose so cleanly. The changeset is just data you can inspect, transform, or reject before persisting.

Shape 3: the conn / socket pipeline

In Phoenix, HTTP requests flow through plug pipelines, and LiveView events flow through socket transformations. Both use pipes heavily.

lib/speechwave_web/user_auth.ex:45-47

conn
|> create_or_extend_session(user, params)
|> redirect(to: user_return_to || signed_in_path(conn))

lib/speechwave_web/user_auth.ex:63-65

conn
|> renew_session(nil)
|> delete_resp_cookie(@remember_me_cookie, @remember_me_options)
|> redirect(to: ~p"/")

And the LiveView equivalent: chaining assign calls on a socket.

lib/speechwave_web/live/dashboard_live.ex:132-134

socket
|> assign(renaming_session_id: nil, rename_form: nil)
|> assign(sessions: Talks.list_sessions(socket.assigns.selected_talk.id))
Ruby/Rails equivalent:
# Rack-style (conceptually)
response = conn
  .create_session(user, params)
  .redirect(to: path)

# But Rails hides this, you just call redirect_to
# and the framework handles the response object

Phoenix makes the transformation chain explicit. The conn (connection) is immutable. Each function returns a new conn with changes applied. Same with the LiveView socket.

The Enum module: pipes + collections

When pipes meet collections, you use the Enum module. It's Elixir's equivalent of Ruby's Enumerable methods, but as standalone functions.

lib/speechwave_web/live/session_analytics_live.ex:57-62

defp group_by_slide(totals) do
  totals
  |> Enum.group_by(& &1.slide_number)
  |> Map.new(fn {slide, entries} ->
    {slide, Enum.map(entries, &%{emoji: &1.emoji, count: &1.count})}
  end)
end

Let's unpack this step by step:

  1. totals: a list of maps like %{slide_number: 1, emoji: "๐Ÿ‘", count: 12}
  2. Enum.group_by(& &1.slide_number): groups the list by slide number, producing a map of %{1 => [entries...], 2 => [entries...]}
  3. Map.new(fn {slide, entries} -> ... end): transforms each group, keeping only emoji and count

The capture operator: &

You'll notice & &1.slide_number in the example above. This is Elixir's shorthand for anonymous functions:

ShorthandFull formRuby equivalent
& &1.slide_numberfn x -> x.slide_number end&:slide_number / {|x| x.slide_number}
&(&1 + 1)fn x -> x + 1 end{|x| x + 1}
&String.upcase/1fn x -> String.upcase(x) end&:upcase / method(:upcase)

&1 is the first argument, &2 the second, and so on. The & prefix converts the expression into an anonymous function.

Here's a simpler Enum example from your codebase:

lib/speechwave_web/live/session_analytics_live.ex:23-24

other_sessions =
  Talks.list_sessions(talk.id)
  |> Enum.reject(fn %{session: s} -> s.id == session.id end)

This pipes the sessions list into Enum.reject, filtering out the current session. Notice the pattern match inside the anonymous function. %{session: s} destructures the map to get the session, right in the function argument.

Ruby equivalent:
other_sessions = talk.sessions.reject { |entry| entry[:session].id == session.id }

Common Enum functions (Ruby translations)

ElixirRuby
Enum.map(list, &fun)list.map(&fun)
Enum.filter(list, &fun)list.select(&fun)
Enum.reject(list, &fun)list.reject(&fun)
Enum.find(list, &fun)list.find(&fun) / list.detect(&fun)
Enum.reduce(list, acc, &fun)list.inject(acc, &fun)
Enum.group_by(list, &fun)list.group_by(&fun)
Enum.count(list)list.count
Enum.any?(list, &fun)list.any?(&fun)
Enum.each(list, &fun)list.each(&fun)
Key difference from Ruby: In Ruby, .map is a method on the Array. In Elixir, Enum.map is a function that takes any enumerable as its first argument. This means you always pipe into Enum, and you can use it with any data type that implements the Enumerable protocol (lists, maps, ranges, streams).

When pipes break: intermediate values

Sometimes the data doesn't flow cleanly in one pipe. Your codebase handles this by binding intermediate values:

lib/speechwave/qr_code.ex:9-16

def to_data_uri(url) do
  png_binary =
    url
    |> EQRCode.encode()
    |> EQRCode.png()
    |> IO.iodata_to_binary()

  "data:image/png;base64," <> Base.encode64(png_binary)
end

The pipe chain produces the binary PNG data, which gets bound to png_binary. Then a separate expression builds the final data URI string. This is cleaner than trying to force the string concatenation into the pipe.

Key insight: Don't force everything into one pipe. If the result needs to be used in a different shape than "first argument to the next function," break the chain and bind an intermediate variable. Clarity beats cleverness.

Combining lessons 1 & 2: pattern match + pipe

The real power emerges when you combine pattern matching with pipes. Here's a pattern you'll see throughout your codebase:

lib/speechwave/talks.ex:78-81

def stop_session(%TalkSession{} = session) do
  session
  |> TalkSession.changeset(%{ended_at: DateTime.utc_now() |> DateTime.truncate(:second)})
  |> Repo.update()
end

Pattern matching selects which function clause runs. The pipe orchestrates what happens inside that clause. Together, they replace the Ruby pattern of "one big method with conditionals and method chains."

And from the with construct (a topic for a future lesson, but worth previewing):

lib/speechwave/accounts.ex:226-234

with {:ok, user} <- register_or_get_user_by_email(email),
     {:ok, _identity} <-
       %UserIdentity{}
       |> UserIdentity.changeset(%{provider: provider, uid: uid, user_id: user.id})
       |> Repo.insert() do
  user
else
  {:error, reason} -> Repo.rollback(reason)
end

Here, a pipe chain lives inside a with clause. The pipe builds and inserts the identity; the with pattern matches the result to decide whether to continue or roll back.


Practice

Question 1

What does this expression evaluate to?

"  Hello World  " |> String.trim() |> String.downcase() |> String.split()
Right. Trim removes surrounding whitespace โ†’ "Hello World". Downcase โ†’ "hello world". Split (with no separator) splits on whitespace โ†’ ["hello", "world"]. Each step pipes its result as the first argument to the next function.
Trace through each step. String.trim/1 removes surrounding whitespace. String.downcase/1 lowercases. String.split/1 with no argument splits on whitespace and returns a list. Try again.

Question 2

Rewrite this nested expression using the pipe operator:

Repo.insert(Talk.changeset(%Talk{user_id: 1}, %{title: "Demo"}))

Which is the correct pipe version?

Correct. Start with the struct, pipe into changeset (struct becomes the first arg, attrs is the second), then pipe the changeset into Repo.insert. This is the standard changeset pipeline pattern you'll see throughout Speechwave.
Remember: the pipe inserts the left side as the first argument. Unwrap the nested calls from inside-out: the innermost value is the starting point, each wrapping function becomes the next step. Try again.

Question 3

What does & &1.emoji expand to?

Exactly. &1 represents the first argument to the anonymous function. &1.emoji accesses the emoji field on that argument. The leading & wraps the whole expression into fn x -> x.emoji end.
The & prefix creates an anonymous function. &1 refers to its first argument. So & &1.emoji is a function that takes one argument and accesses its .emoji field. Try again.

Question 4

Why is this pipe chain split into two parts?

png_binary =
  url
  |> EQRCode.encode()
  |> EQRCode.png()
  |> IO.iodata_to_binary()

"data:image/png;base64," <> Base.encode64(png_binary)
Right. The <> string concatenation puts the prefix string first and the encoded result second. Since pipes always insert into the first argument position, you can't naturally pipe into the second position of <>. Binding the intermediate result is the clean solution.
Think about the pipe rule: it always inserts into the first argument position. The final expression is "data:..." <> Base.encode64(png_binary). Where would the piped value go? Try again.

Your next step

Open lib/speechwave_web/user_auth.ex and trace the pipe chains. For each one, identify:

Then open lib/speechwave/accounts.ex and find every changeset pipeline. They all follow the same shape: struct |> changeset(attrs) |> Repo.insert/update.

If the capture operator (&) still feels unclear, try rewriting & &1.emoji as fn x -> x.emoji end in your head every time you see it. The shorthand will become natural within a few readings.