rrrene* About

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”.

Your turn: Liked this post? Retweet this post! 👍