Should I Use `with` or `|>` for Architecting Flow in Elixir Programs?
In the last post we explored how data flows through our program and why it is important to recognize that the business of software is all about new and changing requirements.
We implemented a simple Mix task to convert images to a given format.
We can, as indicated in the last post, achieve the same thing using the with
macro, which gives us a great way to model flow in Elixir programs.
Let’s try with
instead of |>
Using with
, we can refactor our run/1
function.
As in our previous implementation, please note how you can see the above flow in the run/1
function.
defmodule Mix.Tasks.ConvertImages do
use Mix.Task
@default_glob "./image_uploads/*"
@default_target_dir "./tmp"
@default_format "jpg"
def run(argv) do
with {glob, target_dir, format} <- parse_options(argv),
:ok <- validate_options(glob, format),
filenames <- prepare_conversion(glob, target_dir),
results <- convert_images(filenames, target_dir, format) do
report_results(results, target_dir)
end
end
defp parse_options(argv) do
{opts, args, _invalid} =
OptionParser.parse(argv, switches: [target_dir: :string, format: :string])
glob = List.first(args) || @default_glob
target_dir = opts[:target_dir] || @default_target_dir
format = opts[:format] || @default_format
{glob, target_dir, format}
end
defp validate_options(glob, format) do
filenames = Path.wildcard(glob)
if Enum.empty?(filenames) do
raise "No images found."
end
unless Enum.member?(~w[jpg png], format) do
raise "Unrecognized format: #{format}"
end
:ok
end
defp prepare_conversion(glob, target_dir) do
File.mkdir_p!(target_dir)
Path.wildcard(glob)
end
defp convert_images(filenames, target_dir, format) do
Enum.map(filenames, fn filename ->
Converter.convert_image(filename, target_dir, format)
end)
end
defp report_results(results, target_dir) do
IO.puts("Wrote #{Enum.count(results)} images to #{target_dir}.")
end
end
The benefit is obvious: the called function’s first parameter does not have to match the result of the previous function.
Error Handling using with
In the future, we might want to return an error tuple {:error, message}
from validate_options/2
instead of raising, so we can deal with the error in a special way.
The with
macro gives us a great way to handle unexpected return values with an else
clause:
# ...
def run(argv) do
with {glob, target_dir, format} <- parse_options(argv),
:ok <- validate_options(glob, format),
filenames <- prepare_conversion(glob, target_dir),
results <- convert_images(filenames, target_dir, format) do
report_results(results, target_dir)
else
{:error, message} ->
IO.warn(message)
end
end
# ...
defp validate_options(glob, format) do
filenames = Path.wildcard(glob)
cond do
Enum.empty?(filenames) ->
# we are returning an error tuple instead of raising, so that the
# calling function can handle the "error"
{:error, "No images found."}
!Enum.member?(~w[jpg png], format) ->
{:error, "Unrecognized format: #{format}"}
true ->
:ok
end
end
# ...
end
Comparison of the two approaches
So, which one is “better” (for the lack of a better word)?
# Approach 1: using `|>`
def run(argv) do
argv
|> parse_options()
|> validate_options()
|> prepare_conversion()
|> convert_images()
|> report_results()
end
The |>
approach has a certain appeal to it: It forces the code to adopt Elixir’s idiomatic style of putting the to-be-transformed data as the first argument in any function. This provides a kind of natural “interface” or “contract”, which my program complies with throughout.
# Approach 2: using `with`
def run(argv) do
with {glob, target_dir, format} <- parse_options(argv),
:ok <- validate_options(glob, format),
filenames <- prepare_conversion(glob, target_dir),
results <- convert_images(filenames, target_dir, format) do
report_results(results, target_dir)
end
end
But: although most programmers strive for clarity in their interfaces and APIs, one might not always have (or want) this luxury. When collaborating in a fast-moving environment, you might not want to be that dependent on another programmer’s return values early on.
|>
provides guidance and clarity, which can be very helpful when trying to communicate the basic flow of a function, but with
provides more flexibility in dealing with the called functions’ results.
I personally like the |>
approach best for my entrypoint functions (like the run/1
function in a Mix task), since this provides the easiest readability for the general flow of the program. with
on the other hand is unbeatable when constructing flows that involve third-party functions in the lower levels of my code.
UPDATE (2018-04-01): DockYard’s Alex Garibay has published a blog post about “Better Control Flow Using The “with” Macro”.