How to Use the Pipe Operator in Elixir
One of the coolest features of the Elixir language is also one of the simplest. The humble pipe operator |>
enables a syntactical change that can transform the structure of code and make it far simpler both to write and to understand.
In this post, we'll take a look at the pipe operator in Elixir, how to use it, and learn about why it's so powerful.
by Dave Thomas
⭐️⭐️⭐️⭐️
What is the Pipe Operator?
The pipe operator is deceptively simple: it takes the expression on the left-hand side, and passes it as the first argument to the expression on the right:
# these two expressions are the same
function(value)
value |> function()
If the function calls need additional arguments, we can add them in as well:
# again, these two expressions are the same
function(value, arg1, arg2)
value |> function(arg1, arg2)
So far the pipe operator looks like a simple way to restructure our code, but is otherwise not that impressive. The real power of the pipe operator comes from chaining it together with multiple calls, also known as a pipeline:
# A nested expression without the pipeline operator
function_e(function_d(function_c(function_b(function_a(input)))))
# The same expression using the pipeline operator
input
|> function_a
|> function_b
|> function_c
|> function_d
|> function_e
This example shows the power of the pipe operator. Once you get familiar with the syntax, the pipeline version of this code is far simpler to understand: the initial value (input
in this example) comes first in the pipeline, instead of being nested deeply inside a group of function calls.
Why Do We Need the Pipe Operator?
The pipe operator would be an excellent addition to any programming language, but it's especially helpful in a functional language like Elixir.
Why is that? Because functional languages use immutable values. Instead of making calls to modify the state of an object or struct, for example, we need to make an updated copy of the value, usually by passing it into a function. This means that to apply multiple transformations to a value, we end up with deeply nested function calls. If you've heard of parenthesis hell in languages like Lisp, this is what they're talking about: g(f(e(d(c(b(a(value)))))))
.
To take a more realistic example, let's consider a completely hypothetical API for managing "Widgets" in an object-oriented language (like Ruby) vs. a functional language (like Elixir):
# hypothetical API in a language with object orientation (Ruby):
w = Widget.new
w.setColor("#00f")
w.setSize(:large)
w.setShape(:circle)
w.setMaterial(:plastic)
# hypothetical API in Elixir, without a pipeline:
Widget.with_material(
Widget.with_shape(
Widget.with_size(
Widget.with_color(%Widget{}, "#00f"), :large
), :circle
), :plastic
)
The object-oriented example is relatively straightforward: we create an instance and apply changes to do it as needed. The functional example is a little harder to read though: the Widget struct is created as the innermost expression, then we need to wrap more and more functions around it in order to apply our transformations. Adding additional transformations to the OO example would be simple and straightforward, but a bit of a pain in the Elixir example.
So with that in mind, let's rewrite our Elixir example to use the pipeline operator. Note that we haven't changed the function definitions of the API used above, only how we structure the code using a pipeline:
%Widget{}
|> with_color("#00f")
|> with_size(:large)
|> with_shape(:circle)
|> with_material(:plastic)
So while the pipe operator would be a welcome addition to lots of languages, it's especially useful for the kinds of code you encounter in functional languages like Elixir.
Tips for Building Pipelines
While the pipe operator is simple to learn, there are a couple of tips that can help you use it more effectively and master pipelines in Elixir.
Build Pipeline Compatible APIs
The pipe operator uses the first argument to pass data from the previous step, so by convention in Elixir, module functions which transform data should take that data as the first argument and return the same data type.
From our earlier example, imagine we're implementing a function to transform the color of an existing widget. Though the arguments to the function could in theory be in either order, placing the widget argument first makes the function pipeline-compatible:
# 🚫
def set_color(color, widget), do: %{widget | color: color}
# ✅
def set_color(widget, color), do: %{widget | color: color}
If you look through the Elixir standard libraries, or other popular projects, you'll see how this pattern is used–you should structure your APIs in the same way.
Don't Get Too Clever
While pipelines generally make code simpler and easier to understand, it is possible to use pipelines in ways that actually make code more difficult to read. There are a couple of best-practices to follow when using pipelines.
First, while pipelines can start with any expression, it's usually best to start with a simple value rather than the result of something like a control structure. The following code works, but is not very readable:
# 🚫 don't do this - pipelines should start with a simple value
if value do
f1()
else
f2()
end
|> f3()
Similarly, because Elixir control structures are built with macros, it's also possible to pipe data into control structures–but it can greatly sacrifice readability! Once again, this code works, but (in my opinion) is not the best pattern to follow:
# 🚫 don't do this - don't pipe into control structures
value |> if do: f1(), else: f2()
value |> case do
true -> f1()
false -> f2()
end
I'll admit this is a bit of personal preference and style: this pattern is surprisingly common, with many Elixir developers having the opinion that piping into case statements is just fine. That said, it can usually be made cleaner with the help of a simple helper function, as shown below.
Use Helper Functions to Solve Control Flow Issues
Above we saw how using the pipe operator with complex starting expressions or control structures can hurt readability. But this doesn't mean we can't implement pipelines that use control structures.
In both cases, simple helper functions (with pattern matching, where appropriate) can help make code easier to follow:
# 🚫 complicated use of the pipe operator
def process_action(action) do
if action == :update do
update_data()
else
generate_data()
end
|> process_data()
|> case do
{:ok, data} -> save(data)
error -> log_error(error)
end
end
# ✅ better use of the pipe operator with helper functions:
def process_action(action) do
action
|> produce_data()
|> process_data()
|> process_result()
end
def produce_data(:update), do: update_data()
def product_data(_), do: generate_data()
def process_result({:ok, data}), do: save(data)
def process_result(error), do: log_error(error)
Using helper functions is great for simple logic, but as before, if they begin to get too complex, code readability can suffer. Though the pipe operator is powerful and elegant, don't overuse it: if a long pipeline starts to get gnarly, there's nothing wrong with breaking it up into smaller pipelines with other logic in between.
Learn More About Elixir & Pipes
The great thing about the pipe operator in Elixir is not just how it transforms your code, but how it can transform your thinking. It sounds like an exageration, but it's true: I'm surprised at how often I start writing an algorithm by laying out a high-level pipeline, even when the individual pieces aren't yet written.
The pipe is just one the great features of the Elixir language. If you're interested in other ways to learn about how Elixir helps write high-performant, bug-free code, here are some other resources to check out:
- Have a look at the Elixir documentation
- Jump right into an Elixir REPL online to start playing with the language
- Check out the Pragmatic Programmer book Programming Elixir ≥ 1.6 on Amazon