home

Elixir's With Statement

07 Nov 2016

A reason that I'm such a huge fan of Elixir is that everything just seems to click. The code is elegant and expressive while managing to be efficient.

One thing that hasn't clicked until just now is the with statement. It turns out that it's pretty easy to understand and really quite useful. It addresses a specific and not-uncommon problem in a very Elixir-way. What's the problem? Pretend we have the following:

defmodule User do
  defstruct name: nil, dob: nil

  def create(params) do
  end

end

The create function should either create a User struct or return an error if the params are invalid. How would you go about doing this? Here's an example using pipes:

  def create(params) do
    %User{}
      |> parse_dob(params["dob"])
      |> parse_name(params["name"])
  end

  defp parse_dob(user, nil), do: {:error, "dob is required"}
  defp parse_dob(user, dob) when is_integer(dob), do: %{q | dob: dob}
  defp parse_dob(_user, _invalid), do: {:error "dob must be an integer"}

  defp parse_name(_user, {:error, _} = err), do: err
  defp parse_name(user, nil), do: {:error, "name is required"}
  defp parse_name(user, ""), do: parse_name(user, nil)
  defp parse_name(user, name), do: %{q | name: name}

The problem with this approach is that every function in the chain needs to handle the case where any function before it returned an error. It's clumsy, both because it isn't pretty and because it isn't flexible. Any new return type that we introduced has to be handled by all functions in the chain.

The pipe operator is great when all functions are acting on a consistent piece of data. It falls apart when we introduce variability. That's where with comes in. with is a lot like a |> except that it allows you to match each intermediary result.

Let's rewrite our code:

  def create(params) do
    with {:ok, dob} <- parse_dob(params["dob"]),
         {:ok, name} <- parse_naem(params["name"])
    do
      %User{dob: dob, name: name}
    else
      # nil -> {:error, ...} an example that we can match here too
      err -> err
    end
  end

  defp parse_dob(nil), do: {:error, "dob is required"}
  defp parse_dob(dob) when is_integer(dob), do: {:ok, %{q | dob: dob}}
  defp parse_dob(_invalid), do: {:error "dob must be an integer"}

  defp parse_name(nil), do: {:error, "name is required"}
  defp parse_name(""), do: parse_name(user, nil)
  defp parse_name(name), do: {:ok, name}

Every statement of with is executed in order. Execution continues as long as left <- right match. As soon as a match fails, the else block is executed. Within the else block we can match against whatever WAS returned. If all statements match, the do block is executed and has access to all the local variables in the with block.

Got it? Here's a test. Why did we have to change our success cases so that they'd return {:ok, dob} and {:ok, name} instead of just dob and name?

If we didn't return {:ok, X}, our match would look like:

  def create(params) do
    with dob <- parse_dob(params["dob"]),
         name <- parse_naem(params["name"])
    do
      %User{dob: dob, name: name}
    else
      err -> err
    end
  end
  ...

However, a variable on the left side matches everything. In other words, the above would treat the {:error, "BLAH"} as matches and continue down the "success" path.

blog comments powered by Disqus