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.
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.
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.
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.
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.
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.
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:
%Talk{user_id: user.id}
|> Talk.changeset(attrs)
|> Repo.insert()
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.
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))
# 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.
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:
totals: a list of maps like %{slide_number: 1, emoji: "๐", count: 12}Enum.group_by(& &1.slide_number): groups the list by slide number, producing a map of %{1 => [entries...], 2 => [entries...]}Map.new(fn {slide, entries} -> ... end): transforms each group, keeping only emoji and count&You'll notice & &1.slide_number in the example above. This is Elixir's shorthand for anonymous functions:
| Shorthand | Full form | Ruby equivalent |
|---|---|---|
& &1.slide_number | fn x -> x.slide_number end | &:slide_number / {|x| x.slide_number} |
&(&1 + 1) | fn x -> x + 1 end | {|x| x + 1} |
&String.upcase/1 | fn 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.
other_sessions = talk.sessions.reject { |entry| entry[:session].id == session.id }
| Elixir | Ruby |
|---|---|
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) |
.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).
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.
The real power emerges when you combine pattern matching with pipes. Here's a pattern you'll see throughout your codebase:
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.
What does this expression evaluate to?
" Hello World " |> String.trim() |> String.downcase() |> String.split()
"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.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.Rewrite this nested expression using the pipe operator:
Repo.insert(Talk.changeset(%Talk{user_id: 1}, %{title: "Demo"}))
Which is the correct pipe version?
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.What does & &1.emoji expand to?
&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.& 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.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)
<> 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."data:..." <> Base.encode64(png_binary). Where would the piped value go? Try again.Open lib/speechwave_web/user_auth.ex and trace the pipe chains. For each one, identify:
conn or socket)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.