Unreadable Nesting
In Part Five, we saw how recursion replaces loops. Now let’s look at a different readability problem: nested function calls.
Say you need to take a user’s input, trim whitespace, convert it to lowercase, and capitalize the first letter. In Elixir, without the pipe operator, you’d write:
String.capitalize(String.downcase(String.trim(" HELLO WORLD ")))
# "Hello world"You have to read this from the inside out. The first operation (trim) is buried in the middle, and the last operation (capitalize) is on the outside. The more steps you add, the worse it gets.
If you’re coming from OOP, you might be used to method chaining, which reads left to right:
# Python - method chaining
" HELLO WORLD ".strip().lower().capitalize()
# "Hello world"// JavaScript - method chaining
" HELLO WORLD ".trim().toLowerCase()
// "hello world"
// (JavaScript doesn't have a direct capitalize method)
Method chaining reads nicely, but it only works because each method is attached to the object. In functional programming, functions aren’t bound to objects. They’re standalone. So how do we get the same readability?
The Pipe Operator
Elixir’s pipe operator (|>) solves this. It takes the result of the expression on the left and passes it as the first argument to the function on the right:
" HELLO WORLD "
|> String.trim()
|> String.downcase()
|> String.capitalize()
# "Hello world"Now you can read it top to bottom, in the order things actually happen:
- Start with
" HELLO WORLD " - Trim whitespace
- Convert to lowercase
- Capitalize first letter
Each line is one clear step. No nesting, no reading inside-out.
How |> Works Under the Hood
The pipe operator is simple - it rewrites your code at compile time. This:
value |> function(arg2, arg3)Becomes this:
function(value, arg2, arg3)The left side always becomes the first argument. That’s all it does. This is why Elixir’s standard library consistently puts the “data” argument first. It makes everything pipeable.
A few more examples to make this concrete:
# These two are identical:
String.split("hello world", " ")
"hello world" |> String.split(" ")
# These two are also identical:
Enum.map([1, 2, 3], fn x -> x * 2 end)
[1, 2, 3] |> Enum.map(fn x -> x * 2 end)Building Data Transformation Pipelines
The pipe operator is at its best when you chain multiple transformations together.
Example 1: Cleaning User Input
A user types in their email with extra spaces and mixed casing:
raw_email = " Jose.Reyes@Example.COM "
clean_email =
raw_email
|> String.trim()
|> String.downcase()
IO.puts(clean_email) # "jose.reyes@example.com"Without the pipe:
clean_email = String.downcase(String.trim(raw_email))With two steps, the nested version is still manageable. But pipelines make the intent clearer: “take the raw email, trim it, then downcase it.”
Example 2: Processing a List of Numbers
You want to take a list of numbers, keep only the even ones, double them, and sort the result:
[5, 3, 8, 1, 4, 7, 2, 6]
|> Enum.filter(fn x -> rem(x, 2) == 0 end)
|> Enum.map(fn x -> x * 2 end)
|> Enum.sort()
# [4, 8, 12, 16]Each step is a small operation. You can read what happens at each stage.
Compare with OOP:
# Python
numbers = [5, 3, 8, 1, 4, 7, 2, 6]
evens = [x for x in numbers if x % 2 == 0]
doubled = [x * 2 for x in evens]
result = sorted(doubled)
# [4, 8, 12, 16]// JavaScript - method chaining on arrays
[5, 3, 8, 1, 4, 7, 2, 6]
.filter(x => x % 2 === 0)
.map(x => x * 2)
.sort((a, b) => a - b)
// [4, 8, 12, 16]
The JavaScript version looks similar to Elixir’s pipes. The key difference is that JavaScript’s chaining only works with methods that belong to the Array prototype. Elixir’s pipe operator works with any function, even ones you write yourself.
Example 3: Transforming a List of Strings
Here we process a list of names. Trim whitespace, capitalize each name, and remove any that are empty:
[" alice ", "", "BOB", " charlie ", " ", "diana"]
|> Enum.map(&String.trim/1)
|> Enum.reject(fn name -> name == "" end)
|> Enum.map(&String.capitalize/1)
# ["Alice", "Bob", "Charlie", "Diana"]Notice the &String.trim/1 syntax. This is a function capture, shorthand for fn x -> String.trim(x) end. Since each element just needs to be passed directly to the function, we can use this shorter form.
Contrast: Method Chaining vs Pipe Operator
OOP’s method chaining and Elixir’s pipe operator look similar but work differently:
Method chaining (OOP): Methods live on the object. You can only chain methods that the object knows about.
// Java - chaining only works with the object's own methods
String result = " HELLO "
.trim()
.toLowerCase()
.replace("hello", "hi");
// Can only call String methodsPipe operator (FP): Functions are independent. You can pipe into any function that takes the data as its first argument.
# Elixir - pipe into any function, even your own
defmodule MyString do
def add_greeting(name), do: "Hello, #{name}!"
end
" alice "
|> String.trim()
|> String.capitalize()
|> MyString.add_greeting()
# "Hello, Alice!"This matters. In OOP, if you want to add a new transformation, you need to extend the class or create a wrapper. In Elixir, you just write a function and pipe into it.
Why Small, Composable Functions Matter
The pipe operator pushes you toward writing small, focused functions that do one thing well. Each function takes data in and returns data out. This makes them:
- Easy to test - each function can be tested on its own
- Easy to reuse - compose them differently for different tasks
- Easy to read - each function has a single responsibility
defmodule TextProcessor do
def remove_punctuation(text) do
String.replace(text, ~r/[^\w\s]/, "")
end
def normalize_whitespace(text) do
text
|> String.trim()
|> String.replace(~r/\s+/, " ")
end
def word_count(text) do
text
|> String.split(" ")
|> length()
end
end
# Now compose them freely:
" Hello, world! How are you? "
|> TextProcessor.remove_punctuation()
|> TextProcessor.normalize_whitespace()
|> TextProcessor.word_count()
# 5Each function is simple on its own. The pipe operator lets you combine them in whatever order you need. You can rearrange, add, or remove steps without rewriting everything.
Common Patterns and Gotchas
Debugging with IO.inspect
You can insert IO.inspect anywhere in a pipeline to see intermediate values. Since IO.inspect returns the value it receives, it doesn’t break the pipeline:
[5, 3, 8, 1, 4, 7, 2, 6]
|> Enum.filter(fn x -> rem(x, 2) == 0 end)
|> IO.inspect(label: "after filter")
|> Enum.map(fn x -> x * 2 end)
|> IO.inspect(label: "after map")
|> Enum.sort()
# Output:
# after filter: [8, 4, 2, 6]
# after map: [16, 8, 4, 12]
# [4, 8, 12, 16]Very useful when you’re trying to figure out what’s happening at each step.
When the First Argument Doesn’t Fit
Sometimes the data argument isn’t in the first position. For example, String.replace/3 takes the string first (pipe-friendly), but what if you encounter a function where the data goes in a different position?
You can use an anonymous function:
# If a function takes data as the second argument:
some_value
|> then(fn x -> some_function("fixed_arg", x) end)The then/2 function exists for this. It passes the piped value into an anonymous function.
Keep Pipelines Focused
If a pipeline grows beyond 5-7 steps, consider breaking it into named functions:
# Instead of one long pipeline:
data
|> step1()
|> step2()
|> step3()
|> step4()
|> step5()
|> step6()
|> step7()
# Break it into meaningful chunks:
data
|> validate_input()
|> transform_data()
|> format_output()
# Where each function is its own small pipeline
defp validate_input(data) do
data
|> check_required_fields()
|> normalize_values()
endKey Takeaways
- The pipe operator (
|>) passes the result of the left side as the first argument to the right side - It eliminates nested function calls, making code read top-to-bottom instead of inside-out
- Elixir’s standard library puts the data argument first so functions work naturally with pipes
- Unlike method chaining, pipes work with any function, including your own
- Pipes push you toward small, composable functions that do one thing well
- Use
IO.inspectto debug intermediate values without breaking the pipeline - Use
then/2when you need to pipe into a function where the data isn’t the first argument
Try It Yourself
Open iex and practice these pipe operator exercises.
Exercise 1: String Pipeline
Write a pipeline that takes a sentence, splits it into words, reverses the list of words, and joins them back with spaces:
"the quick brown fox"
|> ???
|> ???
|> ???
# Should return "fox brown quick the"
# Hint: String.split/2, Enum.reverse/1, Enum.join/2Exercise 2: Number Crunching Pipeline
Write a pipeline that takes a list of numbers from 1 to 10, keeps only odd numbers, squares each one, and sums them:
1..10
|> Enum.to_list()
|> ???
|> ???
|> ???
# Should return 165 (1 + 9 + 25 + 49 + 81)
# Hint: Enum.filter/2, Enum.map/2, Enum.sum/1Exercise 3: Build Your Own Pipeline Functions
Create a module with small functions that you can compose with pipes. Write functions to process a list of raw product names:
defmodule ProductCleaner do
def trim_all(products), do: ???
def remove_empty(products), do: ???
def capitalize_all(products), do: ???
def sort_alphabetically(products), do: ???
end
[" widget ", "", " GADGET", " gizmo ", " ", "Doohickey"]
|> ProductCleaner.trim_all()
|> ProductCleaner.remove_empty()
|> ProductCleaner.capitalize_all()
|> ProductCleaner.sort_alphabetically()
# Should return ["Doohickey", "Gadget", "Gizmo", "Widget"]Official Documentation to Help You Learn
Part Six | Functional Programming Through Elixir series
Previous in series: Part Five - Recursion Over Iteration
Next in series: Part Seven - Higher-Order Functions (Coming Soon)