DEV Community

Cover image for Learning Elixir: Pipe Operator
João Paulo Abreu
João Paulo Abreu

Posted on

Learning Elixir: Pipe Operator

The pipe operator (|>) transforms the way we compose functions in Elixir, turning nested function calls into elegant, readable pipelines that flow naturally from left to right. This simple yet powerful operator embodies the essence of functional programming by making data transformations explicit and sequential, allowing you to express complex operations as a series of simple, composable steps. Rather than wrapping functions inside functions, creating hard-to-read nested structures, the pipe operator lets you chain operations together in a way that mirrors how we naturally think about data processing. In this article, we'll explore how this foundational Elixir feature can dramatically improve your code's readability and maintainability.

Note: The examples in this article use Elixir 1.18.3. While most operations should work across different versions, some functionality might vary.

Table of Contents

Introduction

In previous articles, we've explored pattern matching in functions and how it enables elegant, declarative code. Now, we'll discover how the pipe operator complements pattern matching and other Elixir features to create expressive data transformation pipelines.

The pipe operator addresses a fundamental challenge in functional programming: how to compose multiple function calls without creating deeply nested, hard-to-read code. Instead of reading functions from the inside out, the pipe operator allows us to read them from left to right, following the natural flow of data transformation.

Consider this simple example of data processing without the pipe operator:

String.split(String.downcase(String.trim(" Hello World ")), " ") 
Enter fullscreen mode Exit fullscreen mode

With the pipe operator, the same operation becomes:

" Hello World " |> String.trim() |> String.downcase() |> String.split(" ") 
Enter fullscreen mode Exit fullscreen mode

The transformation is immediately clearer: we start with a string, trim it, convert it to lowercase, and split it by spaces. This linear flow makes the code self-documenting and easier to understand.

Understanding the Pipe Operator

How It Works

The pipe operator takes the result of the expression on its left and passes it as the first argument to the function on its right. This simple rule enables powerful function composition.

# Without pipe operator result = function_c(function_b(function_a(value))) # With pipe operator result = value |> function_a() |> function_b() |> function_c() 
Enter fullscreen mode Exit fullscreen mode

The First Argument Rule

The key to understanding the pipe operator is remembering that it always passes the value as the first argument to the next function:

# This: "hello" |> String.upcase() # Is equivalent to: String.upcase("hello") # And this: "hello world" |> String.split(" ") # Is equivalent to: String.split("hello world", " ") 
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> "hello" |> String.upcase() "HELLO" iex> "hello world" |> String.split(" ") ["hello", "world"] iex> 10 |> Integer.to_string() "10" iex> [1, 2, 3] |> Enum.map(&(&1 * 2)) [2, 4, 6] 
Enter fullscreen mode Exit fullscreen mode

Basic Usage

Simple Transformations

Let's start with basic examples to understand how the pipe operator works:

defmodule BasicPipes do def process_name(name) do name |> String.trim() |> String.downcase() |> String.capitalize() end def calculate_total(items) do items |> Enum.map(&(&1.quantity * &1.price)) |> Enum.sum() end end 
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> BasicPipes.process_name(" john DOE ") "John doe" iex> items = [%{quantity: 2, price: 10.5}, %{quantity: 1, price: 5.0}] [%{price: 10.5, quantity: 2}, %{price: 5.0, quantity: 1}] iex> BasicPipes.calculate_total(items) 26.0 
Enter fullscreen mode Exit fullscreen mode

Multiple Arguments

When a function requires multiple arguments, the piped value becomes the first argument:

defmodule MultiArgExample do def format_currency(amount) do amount |> to_float() |> Float.round(2) |> Float.to_string() |> String.split(".") |> format_parts() end defp to_float(value) when is_float(value), do: value defp to_float(value) when is_integer(value), do: value / 1 defp format_parts([whole, decimal]) do "$#{whole}.#{String.pad_trailing(decimal, 2, "0")}" end defp format_parts([whole]) do "$#{whole}.00" end end 
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> MultiArgExample.format_currency(10.5) "$10.50" iex> MultiArgExample.format_currency(10) "$10.00" iex> MultiArgExample.format_currency(10.567) "$10.57" 
Enter fullscreen mode Exit fullscreen mode

Working with Different Data Types

The pipe operator works with any data type:

defmodule DataTypeExamples do def process_list(list) do list |> Enum.filter(&(&1 > 0)) |> Enum.map(&(&1 * 2)) |> Enum.sort(:desc) end def process_map(map) do map |> Map.put(:processed, true) |> Map.update!(:count, &(&1 + 1)) |> Map.delete(:temp) end def process_tuple(tuple) do tuple |> Tuple.to_list() |> Enum.reverse() |> List.to_tuple() end end 
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> DataTypeExamples.process_list([3, -1, 4, 1, 5, -2]) [10, 8, 6, 2] iex> DataTypeExamples.process_map(%{count: 5, temp: "remove me", name: "test"}) %{count: 6, name: "test", processed: true} iex> DataTypeExamples.process_tuple({1, 2, 3, 4}) {4, 3, 2, 1} 
Enter fullscreen mode Exit fullscreen mode

Transformation Pipelines

Building Complex Pipelines

The real power of the pipe operator becomes apparent when building complex data transformation pipelines:

defmodule UserProcessor do def process_user_data(user_map) do user_map |> validate_fields() |> normalize_data() |> enrich_with_defaults() |> calculate_derived_fields() |> format_output() end defp validate_fields(data) when is_map(data) do data |> Map.take([:name, :email, :age]) |> Enum.filter(fn {_k, v} -> v != nil end) |> Map.new() end defp validate_fields(_), do: %{} defp normalize_data(data) do data |> Map.update(:name, "", &String.trim/1) |> Map.update(:email, "", &String.downcase/1) |> Map.update(:age, 0, &ensure_integer/1) end defp ensure_integer(value) when is_integer(value), do: value defp ensure_integer(value) when is_binary(value) do case Integer.parse(value) do {int, _} -> int :error -> 0 end end defp ensure_integer(_), do: 0 defp enrich_with_defaults(data) do defaults = %{ status: "active", created_at: DateTime.utc_now() |> DateTime.to_string() } Map.merge(defaults, data) end defp calculate_derived_fields(data) do data |> Map.put(:is_adult, data[:age] >= 18) |> Map.put(:username, generate_username(data[:email])) end defp generate_username(""), do: "" defp generate_username(email) do email |> String.split("@") |> List.first() |> String.replace(".", "_") end defp format_output(data) do data |> Map.to_list() |> Enum.sort() |> Enum.map(fn {k, v} -> "#{k}: #{inspect(v)}" end) |> Enum.join("\n") end end 
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> user_data = %{name: " John Doe ", email: "JOHN.DOE@EXAMPLE.COM", age: "25"} %{age: "25", email: "JOHN.DOE@EXAMPLE.COM", name: " John Doe "} iex> UserProcessor.process_user_data(user_data) "age: 25\ncreated_at: \"2025-05-31 14:30:00.123456Z\"\nemail: \"john.doe@example.com\"\nis_adult: true\nname: \"John Doe\"\nstatus: \"active\"\nusername: \"john_doe\"" iex> incomplete_data = %{name: "Alice", age: "17"} %{age: "17", name: "Alice"} iex> UserProcessor.process_user_data(incomplete_data) "age: 17\ncreated_at: \"2025-05-31 14:30:01.456789Z\"\nemail: \"\"\nis_adult: false\nname: \"Alice\"\nstatus: \"active\"\nusername: \"\"" iex> empty_data = %{} %{} iex> UserProcessor.process_user_data(empty_data) "age: 0\ncreated_at: \"2025-05-31 14:30:02.789012Z\"\nemail: \"\"\nis_adult: false\nstatus: \"active\"\nusername: \"\"" iex> invalid_data = "not a map" "not a map" iex> UserProcessor.process_user_data(invalid_data) "age: 0\ncreated_at: \"2025-05-31 14:30:03.345678Z\"\nemail: \"\"\nis_adult: false\nstatus: \"active\"\nusername: \"\"" 
Enter fullscreen mode Exit fullscreen mode

Conditional Pipelines

Sometimes you need conditional logic within pipelines:

defmodule ConditionalPipeline do def process_order(order) do order |> validate_order() |> maybe_apply_discount() |> calculate_tax() |> add_shipping() |> finalize_order() end defp validate_order(order) do if order.items == [] do {:error, "Order has no items"} else {:ok, order} end end defp maybe_apply_discount({:error, _} = error), do: error defp maybe_apply_discount({:ok, order}) do if order.total > 100 do {:ok, %{order | total: order.total * 0.9} |> Map.put(:discount_applied, true)} else {:ok, Map.put(order, :discount_applied, false)} end end defp calculate_tax({:error, _} = error), do: error defp calculate_tax({:ok, order}) do tax = order.total * 0.08 {:ok, %{order | total: order.total + tax} |> Map.put(:tax, tax)} end defp add_shipping({:error, _} = error), do: error defp add_shipping({:ok, order}) do shipping = if order.total > 50, do: 0, else: 10 {:ok, %{order | total: order.total + shipping} |> Map.put(:shipping, shipping)} end defp finalize_order({:error, _} = error), do: error defp finalize_order({:ok, order}) do {:ok, Map.put(order, :status, "completed")} end end 
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> order = %{items: ["item1", "item2"], total: 120} %{items: ["item1", "item2"], total: 120} iex> ConditionalPipeline.process_order(order) {:ok, %{ discount_applied: true, items: ["item1", "item2"], shipping: 0, status: "completed", tax: 8.64, total: 116.64 }} iex> small_order = %{items: ["item1"], total: 30} %{items: ["item1"], total: 30} iex> ConditionalPipeline.process_order(small_order) {:ok, %{ discount_applied: false, items: ["item1"], shipping: 10, status: "completed", tax: 2.4, total: 42.4 }} iex> empty_order = %{items: [], total: 0} %{items: [], total: 0} iex> ConditionalPipeline.process_order(empty_order) {:error, "Order has no items"} 
Enter fullscreen mode Exit fullscreen mode

Combining with Pattern Matching

The pipe operator works seamlessly with pattern matching, creating powerful and expressive code:

defmodule PipeAndPattern do def analyze_response(response) do response |> parse_response() |> extract_data() |> process_by_type() |> format_result() end defp parse_response(%{"type" => _, "data" => _} = response) do {:ok, response} end defp parse_response(%{} = response) do {:error, :missing_fields} end defp parse_response(_) do {:error, :invalid_format} end defp extract_data({:error, _} = error), do: error defp extract_data({:ok, %{"type" => type, "data" => data}}) do {:ok, {type, data}} end defp process_by_type({:error, _} = error), do: error defp process_by_type({:ok, {"user", data}}) do {:ok, {:user, process_user(data)}} end defp process_by_type({:ok, {"product", data}}) do {:ok, {:product, process_product(data)}} end defp process_by_type({:ok, {type, _}}) do {:error, {:unknown_type, type}} end defp process_user(data) do data |> Map.take(["name", "email"]) |> Map.put("processed_at", DateTime.utc_now()) end defp process_product(data) do data |> Map.take(["name", "price"]) |> Map.update("price", 0.0, &parse_price/1) end defp parse_price(price) when is_number(price), do: price defp parse_price(price) when is_binary(price) do case Float.parse(price) do {float, _} -> float :error -> 0.0 end end defp parse_price(_), do: 0.0 defp format_result({:error, :missing_fields}), do: "Error: missing_fields" defp format_result({:error, :invalid_format}), do: "Error: invalid_format" defp format_result({:error, {:unknown_type, type}}), do: "Error: unknown_type #{type}" defp format_result({:ok, {:user, data}}) do "Processed user: #{data["name"]} (#{data["email"]}) at #{data["processed_at"]}" end defp format_result({:ok, {:product, data}}) do "Processed product: #{data["name"]} - $#{data["price"]}" end end 
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> user_response = %{"type" => "user", "data" => %{"name" => "Alice", "email" => "alice@example.com", "age" => 30}} %{ "data" => %{"age" => 30, "email" => "alice@example.com", "name" => "Alice"}, "type" => "user" } iex> PipeAndPattern.analyze_response(user_response) "Processed user: Alice (alice@example.com) at 2025-05-31 14:25:11.668875Z" iex> product_response = %{"type" => "product", "data" => %{"name" => "Widget", "price" => "19.99", "stock" => 100}} %{ "data" => %{"name" => "Widget", "price" => "19.99", "stock" => 100}, "type" => "product" } iex> PipeAndPattern.analyze_response(product_response) "Processed product: Widget - $19.99" iex> unknown_type = %{"type" => "order", "data" => %{"id" => 123}} %{"data" => %{"id" => 123}, "type" => "order"} iex> PipeAndPattern.analyze_response(unknown_type) "Error: unknown_type order" iex> invalid_response = %{"name" => "Missing type and data fields"} %{"name" => "Missing type and data fields"} iex> PipeAndPattern.analyze_response(invalid_response) "Error: :missing_fields" iex> PipeAndPattern.analyze_response("not a map") "Error: :invalid_format" 
Enter fullscreen mode Exit fullscreen mode

Using case and with in Pipelines

Sometimes you need more complex control flow within pipelines:

defmodule AdvancedPipeline do def process_file(filename) do filename |> File.read() |> case do {:ok, content} -> content |> String.split("\n") |> Enum.map(&process_line/1) |> Enum.filter(&(&1 != :skip)) {:error, reason} -> {:error, "Failed to read file: #{reason}"} end end defp process_line(line) do line |> String.trim() |> case do "" -> :skip "#" <> _comment -> :skip data -> String.split(data, ",") end end def complex_validation(data) do data |> Map.new() |> then(fn map -> with {:ok, validated_name} <- validate_name(map[:name]), {:ok, validated_age} <- validate_age(map[:age]), {:ok, validated_email} <- validate_email(map[:email]) do %{ name: validated_name, age: validated_age, email: validated_email } else {:error, _} = error -> error end end) end defp validate_name(nil), do: {:error, "Name is required"} defp validate_name(name) when is_binary(name) and byte_size(name) > 0, do: {:ok, name} defp validate_name(_), do: {:error, "Invalid name"} defp validate_age(nil), do: {:error, "Age is required"} defp validate_age(age) when is_integer(age) and age >= 0, do: {:ok, age} defp validate_age(_), do: {:error, "Invalid age"} defp validate_email(nil), do: {:error, "Email is required"} defp validate_email(email) when is_binary(email) do if String.contains?(email, "@"), do: {:ok, email}, else: {:error, "Invalid email"} end defp validate_email(_), do: {:error, "Invalid email"} end 
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> content = "name,age\nAlice,25\n# comment\nBob,30\n" "name,age\nAlice,25\n# comment\nBob,30\n" iex> File.write!("/tmp/test.csv", content) :ok iex> AdvancedPipeline.process_file("/tmp/test.csv") [["name", "age"], ["Alice", "25"], ["Bob", "30"]] iex> AdvancedPipeline.process_file("/tmp/nonexistent.csv") {:error, "Failed to read file: enoent"} iex> valid_data = [name: "Alice", age: 25, email: "alice@example.com"] [name: "Alice", age: 25, email: "alice@example.com"] iex> AdvancedPipeline.complex_validation(valid_data) %{age: 25, email: "alice@example.com", name: "Alice"} iex> invalid_data = [name: "Charlie", age: -5, email: "invalid-email"] [name: "Charlie", age: -5, email: "invalid-email"] iex> AdvancedPipeline.complex_validation(invalid_data) {:error, "Invalid age"} iex> missing_name = [age: 30, email: "bob@example.com"] [age: 30, email: "bob@example.com"] iex> AdvancedPipeline.complex_validation(missing_name) {:error, "Name is required"} iex> File.rm("/tmp/test.csv") :ok 
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls and Solutions

Pitfall 1: Wrong Argument Position

The pipe operator always passes the value as the first argument. This can cause issues with functions that expect the piped value in a different position:

defmodule ArgumentPosition do # Problem: Enum.member? expects the enumerable as the second argument def has_admin_role_wrong(user) do user |> Map.get(:roles, []) # This won't work as expected! # |> Enum.member?("admin") end # Solution 1: Use an anonymous function def has_admin_role_v1(user) do user |> Map.get(:roles, []) |> then(fn roles -> Enum.member?(roles, "admin") end) end # Solution 2: Use the capture operator with proper positioning def has_admin_role_v2(user) do user |> Map.get(:roles, []) |> then(&Enum.member?(&1, "admin")) end # Solution 3: Create a helper function with arguments in the right order def has_admin_role_v3(user) do user |> Map.get(:roles, []) |> contains?("admin") end defp contains?(list, item), do: Enum.member?(list, item) end 
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> user = %{roles: ["user", "admin"]} %{roles: ["user", "admin"]} iex> ArgumentPosition.has_admin_role_v1(user) true iex> ArgumentPosition.has_admin_role_v2(user) true iex> ArgumentPosition.has_admin_role_v3(user) true 
Enter fullscreen mode Exit fullscreen mode

Pitfall 2: Side Effects in Pipelines

Pipelines should be used for transformations, not side effects:

defmodule SideEffectPitfall do # Bad: Mixing side effects with transformations def process_data_bad(data) do data |> validate() |> IO.inspect(label: "After validation") # Side effect! |> transform() |> save_to_db() # Side effect! |> format_response() end # Better: Separate concerns def process_data_good(data) do with {:ok, validated} <- validate(data), {:ok, transformed} <- transform(validated), {:ok, saved} <- save_to_db(transformed) do format_response(saved) else {:error, reason} -> {:error, reason} end end # Best: Use tap for debugging without breaking the pipeline def process_data_with_debugging(data) do data |> validate() |> tap(&IO.inspect(&1, label: "After validation")) |> transform() |> tap(&log_transformation(&1)) |> format_response() end defp validate(data), do: {:ok, data} defp transform({:ok, data}), do: {:ok, Map.put(data, :transformed, true)} defp transform(data), do: {:ok, Map.put(data, :transformed, true)} defp save_to_db({:ok, data}), do: {:ok, Map.put(data, :id, :rand.uniform(1000))} defp save_to_db(data), do: {:ok, Map.put(data, :id, :rand.uniform(1000))} defp format_response({:ok, data}), do: {:success, data} defp format_response(data), do: {:success, data} defp log_transformation({:ok, data}) do IO.puts("Transformation complete: #{inspect(data)}") end defp log_transformation(data) do IO.puts("Transformation complete: #{inspect(data)}") end end 
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> data = %{name: "Alice", age: 25} %{name: "Alice", age: 25} iex> SideEffectPitfall.process_data_bad(data) After validation: {:ok, %{name: "Alice", age: 25}} {:success, %{age: 25, id: 432, name: "Alice", transformed: true}} iex> SideEffectPitfall.process_data_good(data) {:success, %{age: 25, id: 876, name: "Alice", transformed: true}} iex> SideEffectPitfall.process_data_with_debugging(data) After validation: {:ok, %{name: "Alice", age: 25}} Transformation complete: {:ok, %{name: "Alice", age: 25, transformed: true}} {:success, %{age: 25, name: "Alice", transformed: true}} iex> empty_data = %{} %{} iex> SideEffectPitfall.process_data_with_debugging(empty_data) After validation: {:ok, %{}} Transformation complete: {:ok, %{transformed: true}} {:success, %{transformed: true}} 
Enter fullscreen mode Exit fullscreen mode

Pitfall 3: Overusing the Pipe Operator

Not every sequence of function calls benefits from the pipe operator:

defmodule PipeOveruse do # Overused: Single transformation doesn't need pipe def get_name_bad(user) do user |> Map.get(:name) end # Better: Direct function call def get_name_good(user) do Map.get(user, :name) end # Overused: Complex branching logic def complex_logic_bad(data) do data |> validate() |> case do {:ok, valid} -> valid |> process() |> case do {:ok, processed} -> processed |> finalize() error -> error end error -> error end end # Better: Use with for complex error handling def complex_logic_good(data) do with {:ok, valid} <- validate(data), {:ok, processed} <- process(valid), {:ok, finalized} <- finalize(processed) do {:ok, finalized} end end defp validate(%{valid: true} = data), do: {:ok, data} defp validate(%{valid: false}), do: {:error, "Invalid data"} defp validate(data), do: {:ok, data} defp process(data), do: {:ok, Map.put(data, :processed, true)} defp finalize(data), do: {:ok, Map.put(data, :finalized, true)} end 
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> user = %{name: "Alice", age: 25} %{age: 25, name: "Alice"} iex> PipeOveruse.get_name_bad(user) "Alice" iex> PipeOveruse.get_name_good(user) "Alice" iex> user_no_name = %{age: 30} %{age: 30} iex> PipeOveruse.get_name_bad(user_no_name) nil iex> PipeOveruse.get_name_good(user_no_name) nil iex> valid_data = %{id: 1, valid: true} %{id: 1, valid: true} iex> PipeOveruse.complex_logic_bad(valid_data) {:ok, %{finalized: true, id: 1, processed: true, valid: true}} iex> PipeOveruse.complex_logic_good(valid_data) {:ok, %{finalized: true, id: 1, processed: true, valid: true}} iex> invalid_data = %{id: 2, valid: false} %{id: 2, valid: false} iex> PipeOveruse.complex_logic_bad(invalid_data) {:error, "Invalid data"} iex> PipeOveruse.complex_logic_good(invalid_data) {:error, "Invalid data"} iex> simple_data = %{name: "test"} %{name: "test"} iex> PipeOveruse.complex_logic_good(simple_data) {:ok, %{finalized: true, name: "test", processed: true}} 
Enter fullscreen mode Exit fullscreen mode

Pitfall 4: Parentheses and Precedence

Be careful with function calls that don't use parentheses:

defmodule ParenthesesPitfall do # Problem: Inconsistent style without parentheses def calculate_wrong(value) do value |> Integer.to_string |> String.upcase # Works but inconsistent style end # Better: Always use parentheses in pipelines for clarity def calculate_good(value) do value |> Integer.to_string() |> then(fn str -> "Value: " <> str end) end # Alternative solution with helper function def calculate_alternative(value) do value |> Integer.to_string() |> prepend("Value: ") end # Problem: Complex expressions without clear grouping def complex_without_parens(value) do value |> Integer.to_string |> String.length |> add_ten end # Better: Clear function calls with parentheses def complex_with_parens(value) do value |> Integer.to_string() |> String.length() |> add_ten() end # Problem: Mixing operators and pipes can be confusing def confusing_mix(values) when is_list(values) do values |> length |> add_ten end # Better: Clear intent with parentheses def clear_intent(values) when is_list(values) do values |> length() |> add_ten() end defp prepend(string, prefix), do: prefix <> string defp add_ten(value), do: value + 10 end 
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> ParenthesesPitfall.calculate_wrong(42) "42" iex> ParenthesesPitfall.calculate_good(42) "Value: 42" iex> ParenthesesPitfall.calculate_alternative(42) "Value: 42" iex> ParenthesesPitfall.complex_without_parens(1234) 14 iex> ParenthesesPitfall.complex_with_parens(1234) 14 iex> ParenthesesPitfall.confusing_mix([1, 2, 3, 4, 5]) 15 iex> ParenthesesPitfall.clear_intent([1, 2, 3, 4, 5]) 15 iex> ParenthesesPitfall.calculate_alternative(999) "Value: 999" iex> large_list = Enum.to_list(1..100) [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, ...] iex> ParenthesesPitfall.clear_intent(large_list) 110 
Enter fullscreen mode Exit fullscreen mode

Advanced Techniques

Custom Pipe-Friendly Functions

Design your functions to work well with the pipe operator:

defmodule PipeFriendly do # Design functions with the "subject" as the first parameter def update_user_name(user, name) do Map.put(user, :name, name) end def add_role(user, role) do Map.update(user, :roles, [role], &[role | &1]) end def set_status(user, status) do Map.put(user, :status, status) end # Now they work beautifully in pipelines def create_admin_user(name, email) do %{email: email} |> update_user_name(name) |> add_role("admin") |> add_role("user") |> set_status("active") end end 
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> PipeFriendly.create_admin_user("Alice", "alice@example.com") %{ email: "alice@example.com", name: "Alice", roles: ["user", "admin"], status: "active" } 
Enter fullscreen mode Exit fullscreen mode

The then Function

Elixir 1.12+ introduced the then function for cases where you need more flexibility:

defmodule ThenExamples do def calculate_statistics(numbers) do numbers |> Enum.sort() |> then(fn sorted -> %{ min: List.first(sorted), max: List.last(sorted), median: median(sorted), mean: mean(numbers) } end) end defp median(sorted_list) do len = length(sorted_list) if rem(len, 2) == 1 do Enum.at(sorted_list, div(len, 2)) else mid = div(len, 2) (Enum.at(sorted_list, mid - 1) + Enum.at(sorted_list, mid)) / 2 end end defp mean(list) do Enum.sum(list) / length(list) end # Using then for conditional transformations def process_optional_field(data, field, transformer \\ & &1) do data |> Map.get(field) |> then(fn nil -> nil value -> transformer.(value) end) end end 
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> ThenExamples.calculate_statistics([5, 2, 8, 1, 9, 3]) %{max: 9, mean: 4.666666666666667, median: 4.0, min: 1} iex> data = %{name: "john doe", age: "25"} %{age: "25", name: "john doe"} iex> ThenExamples.process_optional_field(data, :name, &String.upcase/1) "JOHN DOE" iex> ThenExamples.process_optional_field(data, :missing, &String.upcase/1) nil 
Enter fullscreen mode Exit fullscreen mode

Tap for Side Effects

The tap function allows you to perform side effects without breaking the pipeline:

defmodule TapExamples do require Logger def process_with_logging(data) do data |> validate() |> tap(&log_step("Validation complete", &1)) |> transform() |> tap(&log_step("Transformation complete", &1)) |> enrich() |> tap(&save_checkpoint/1) |> format_output() end defp validate(data), do: Map.put(data, :validated, true) defp transform(data), do: Map.put(data, :transformed, true) defp enrich(data), do: Map.put(data, :enriched, true) defp format_output(data), do: {:ok, data} defp log_step(message, data) do IO.puts("#{message}: #{inspect(Map.keys(data))}") end defp save_checkpoint(data) do # Simulate saving a checkpoint IO.puts("Checkpoint saved with keys: #{inspect(Map.keys(data))}") end end 
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> TapExamples.process_with_logging(%{id: 1, name: "test"}) Validation complete: [:id, :name, :validated] Transformation complete: [:id, :name, :transformed, :validated] Checkpoint saved with keys: [:enriched, :id, :name, :transformed, :validated] {:ok, %{enriched: true, id: 1, name: "test", transformed: true, validated: true}} 
Enter fullscreen mode Exit fullscreen mode

Real-World Examples

API Request Processing

defmodule ApiProcessor do def handle_request(raw_request) do raw_request |> parse_request() |> authenticate() |> authorize() |> validate_params() |> execute_action() |> format_response() |> add_headers() end defp parse_request(raw) do %{ method: raw[:method], path: raw[:path], params: raw[:params] || %{}, headers: raw[:headers] || %{}, user_token: get_in(raw, [:headers, "Authorization"]) } end defp authenticate(%{user_token: nil} = request) do {:error, :unauthorized, request} end defp authenticate(%{user_token: token} = request) do # Simulate token validation if String.starts_with?(token, "valid_") do {:ok, Map.put(request, :user_id, String.slice(token, 6..-1//1))} else {:error, :unauthorized, request} end end defp authorize({:error, _, _} = error), do: error defp authorize({:ok, %{path: path, user_id: user_id} = request}) do # Simulate authorization check if can_access?(user_id, path) do {:ok, request} else {:error, :forbidden, request} end end defp can_access?(_user_id, "/public" <> _), do: true defp can_access?("admin", _), do: true defp can_access?(_, _), do: false defp validate_params({:error, _, _} = error), do: error defp validate_params({:ok, %{params: params} = request}) do if valid_params?(params) do {:ok, request} else {:error, :bad_request, request} end end defp valid_params?(params) do Map.keys(params) |> Enum.all?(&is_binary/1) end defp execute_action({:error, _, _} = error), do: error defp execute_action({:ok, %{method: "GET", path: path} = request}) do {:ok, %{status: 200, body: "GET #{path} successful", request: request}} end defp execute_action({:ok, %{method: method} = request}) do {:ok, %{status: 200, body: "#{method} successful", request: request}} end defp format_response({:error, reason, _request}) do %{ status: status_code(reason), body: %{error: to_string(reason)} } end defp format_response({:ok, response}) do %{ status: response.status, body: response.body } end defp status_code(:unauthorized), do: 401 defp status_code(:forbidden), do: 403 defp status_code(:bad_request), do: 400 defp status_code(_), do: 500 defp add_headers(response) do Map.put(response, :headers, %{ "Content-Type" => "application/json", "X-Request-ID" => generate_request_id() }) end defp generate_request_id do :rand.uniform(1_000_000) |> Integer.to_string() end end 
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> request = %{ method: "GET", path: "/api/users", headers: %{"Authorization" => "valid_admin"}, params: %{"page" => "1"} } %{ headers: %{"Authorization" => "valid_admin"}, method: "GET", params: %{"page" => "1"}, path: "/api/users" } iex> ApiProcessor.handle_request(request) %{ body: "GET /api/users successful", headers: %{"Content-Type" => "application/json", "X-Request-ID" => "881694"}, status: 200 } iex> bad_request = %{method: "POST", path: "/admin", headers: %{"Authorization" => "valid_user"}} %{headers: %{"Authorization" => "valid_user"}, method: "POST", path: "/admin"} iex> ApiProcessor.handle_request(bad_request) %{ status: 403, body: %{error: "forbidden"}, headers: %{"Content-Type" => "application/json", "X-Request-ID" => "931314"} } 
Enter fullscreen mode Exit fullscreen mode

Best Practices

1. Keep Pipelines Readable

Aim for pipelines that tell a story:

# Good: Clear transformation steps def process_order(order) do order |> validate_items() |> calculate_subtotal() |> apply_discounts() |> add_taxes() |> calculate_total() end # Bad: Unclear purpose def process(data) do data |> f1() |> f2() |> f3() |> f4() end 
Enter fullscreen mode Exit fullscreen mode

2. Limit Pipeline Length

Break long pipelines into smaller, named functions:

# Too long def process_everything(data) do data |> step1() |> step2() |> step3() |> step4() |> step5() |> step6() |> step7() |> step8() |> step9() |> step10() end # Better: Group related operations def process_everything(data) do data |> prepare_data() |> process_data() |> finalize_data() end defp prepare_data(data) do data |> step1() |> step2() |> step3() end defp process_data(data) do data |> step4() |> step5() |> step6() |> step7() end defp finalize_data(data) do data |> step8() |> step9() |> step10() end 
Enter fullscreen mode Exit fullscreen mode

3. Design Functions for Piping

Make the "subject" of the operation the first parameter:

# Good: User is the subject, first parameter def update_user_email(user, email) do %{user | email: email} end def add_user_role(user, role) do %{user | roles: [role | user.roles]} end # Usage in pipeline user |> update_user_email("new@example.com") |> add_user_role("admin") # Bad: Subject is not first def update_email(email, user) do %{user | email: email} end # Awkward in pipeline user |> then(&update_email("new@example.com", &1)) 
Enter fullscreen mode Exit fullscreen mode

4. Use Consistent Return Values

Make functions pipeline-friendly with consistent returns:

# Good: Consistent {:ok, result} or {:error, reason} def validate_age(user) do if user.age >= 18 do {:ok, user} else {:error, "User must be 18 or older"} end end def validate_email(user) do if String.contains?(user.email, "@") do {:ok, user} else {:error, "Invalid email format"} end end # Bad: Inconsistent returns def validate_age(user) do if user.age >= 18, do: user, else: nil end def validate_email(user) do if String.contains?(user.email, "@") do true else {:error, "Invalid email"} end end 
Enter fullscreen mode Exit fullscreen mode

5. Handle Errors Gracefully

Design pipelines that can handle errors elegantly:

defmodule ErrorHandling do def safe_pipeline(data) do data |> validate() |> continue_if_ok(&transform/1) |> continue_if_ok(&persist/1) |> continue_if_ok(&notify/1) end defp continue_if_ok({:error, _} = error, _fun), do: error defp continue_if_ok({:ok, data}, fun), do: fun.(data) defp validate(data) do if valid?(data), do: {:ok, data}, else: {:error, "Invalid data"} end defp transform(data), do: {:ok, Map.put(data, :transformed, true)} defp persist(data), do: {:ok, Map.put(data, :persisted, true)} defp notify(data), do: {:ok, Map.put(data, :notified, true)} defp valid?(data), do: is_map(data) end 
Enter fullscreen mode Exit fullscreen mode

Conclusion

The pipe operator is a cornerstone of idiomatic Elixir code, transforming complex nested function calls into clear, linear transformations. By understanding its mechanics and following best practices, you can write code that is not only functional but also highly readable and maintainable.

Key takeaways from this article include:

  • The pipe operator |> passes the result of the left expression as the first argument to the function on the right
  • Pipelines make data transformations explicit and easy to follow
  • Design your functions with the "subject" as the first parameter to make them pipe-friendly
  • Use then and tap for more complex pipeline scenarios
  • Avoid common pitfalls like wrong argument positioning and overuse
  • Break long pipelines into smaller, focused functions
  • Maintain consistent return values for better composability
  • The pipe operator works beautifully with pattern matching and other Elixir features

By mastering the pipe operator, you'll write Elixir code that reads like a clear description of data transformation, making your programs more maintainable and your intent more obvious.

Tip: When refactoring nested function calls, start from the innermost function and work your way out, creating a pipeline that flows from left to right. This often reveals the natural sequence of transformations and can highlight opportunities for extracting reusable functions.

Further Reading

Next Steps

In the upcoming article, we'll explore Function Composition:

Function Composition

  • Understanding function composition in functional programming
  • Creating higher-order functions for composition
  • Building composable function libraries
  • Partial application and currying in Elixir
  • Advanced composition patterns and techniques
  • Combining composition with pipes for powerful abstractions

Function composition takes the concepts we've learned with the pipe operator to the next level, allowing us to build complex functionality from simple, reusable building blocks. We'll explore how to create functions that combine other functions, leading to more abstract and powerful programming patterns.

Top comments (1)

Collapse
 
adolfont profile image
Adolfo Neto

Great post! It inspired me to write this dev.to/adolfont/the-elixir-pipe-op...