DEV Community

Cover image for With statements and error handling
Dan Strandberg
Dan Strandberg

Posted on

With statements and error handling

With statements are great when we need to handle the possibility of failure in series of steps where each step depends on the result of the previous one.

If you haven't used with statements before you might turn to nested case statements. Let's see an example where we compare them both.

Note: I've made a code choice to pinpoint a problem you might run into when using with statements so we later can learn how to address it.

defmodule Example do
  def run(:case, %{user: user, action: action}) do
    case get_user(user) do
      {:ok, user_actions} ->
        case allowed_to_perform(action, user_actions) do
          {:ok, action} ->
            execute_action(user, action)

          nil ->
            handle_not_allowed(user, action)
        end

      nil ->
        handle_user_not_found(user)
    end
  end

  def run(:with, %{user: user, action: action}) do
    with {:ok, user_actions} <- get_user(user),
         {:ok, action} <- allowed_to_perform(action, user_actions) do
      execute_action(user, action)
    else
      nil ->
        {:error, "#{user} not found or not allowed to perform action #{action}"}
    end
  end

  defp get_user("Bob"), do: {:ok, ["read", "create", "edit"]}
  defp get_user("Anton"), do: {:ok, ["read"]}
  defp get_user("Veronica"), do: {:ok, ["read", "create", "edit", "delete"]}
  defp get_user("Sabrina"), do: {:ok, ["read"]}
  defp get_user(_), do: nil

  defp allowed_to_perform(action, allowed) do
    if action in allowed, do: {:ok, action}, else: nil
  end

  defp execute_action(user, action) do
    {:ok, "#{user} is performing action #{action}"}
  end

  defp handle_user_not_found(user) do
    # Do other user related stuff here
    {:error, "User #{user} not found"}
  end

  defp handle_not_allowed(user, action) do
    # Do other action not allowed stuff here
    {:error, "#{user} is not allowed to #{action}"}
  end
end

Let's take the code for a quick spin to see the differences in action. We add our code to example.exs and load it: iex example.exs

iex(1)> Example.run(:case, %{user: "Anton", action: "read"})
{:ok, "Anton is performing action read"}
iex(2)> Example.run(:with, %{user: "Anton", action: "read"})
{:ok, "Anton is performing action read"}

iex(3)> Example.run(:case, %{user: "Anton", action: "create"})
{:error, "Anton is not allowed to create"}
iex(4)> Example.run(:with, %{user: "Anton", action: "create"})
{:error, "Anton is not found or not allowed to perform action create"}

iex(5)> Example.run(:case, %{user: "Jason", action: "read"})
{:error, "User Jason not found"}
iex(6)> Example.run(:with, %{user: "Jason", action: "read"})
{:error, "Jason is not found or not allowed to perform action read"}

As you can see they don't behave the same, the error messages are more specific when using the case statement.

The case statement have an edge here because both get_access/1 and allowed_to_perform/1 returns nil when they fail (on purpose 😈). We can easily handle them separately by calling handle_user_not_found/1 and handle_not_allowed/1 respectively in each case.

For the with statement we match on what we have available in the else block and that's nil, so we don't know which one has failed. One might suggest we should return {:error, reason} and match on that.

It would work, we can match on the reason as long as they are different. In our example the functions are private and local, but this is not always the case, we could have called an external library and be in the same position as now.

We could stick with case statements but I would argue that nested case statements are hard to follow, we only have two levels here but imagine we had three or more.

What we want is to be able to use the with statements but still be able to differentiate between the errors. So are we out of luck? Not yet, there's a "trick" we can use.

Tag, you're it!

By wrapping the code in a tuple we can tag the expressions so we can match on the tags in case of failure.

  def run(:with, %{user: user, action: action}) do
    with {_, {:ok, user_actions}} <- {:get_user, get_user(user)},
         {_, {:ok, action}} <- {:check_allowed, allowed_to_perform(action, user_actions)} do
      execute_action(user, action)
    else
      {:get_user, _} -> handle_user_not_found(user)
      {:check_allowed, _} -> handle_not_allowed(user, action)
    end
  end

On the right side we use {:some_tag, fn(args)} and match on it on the left side. Here we don't care about the tag so we can ignore it and match on the result of the function calls.

If any of the matches fails we enter the else block and now we can tell them apart by matching against our tags :get_user and :check_allowed.

Let's apply the changes and try it: iex example.exs

iex(1)> Example.run(:with, %{user: "Anton", action: "create"})
{:error, "Anton is not allowed to create"}

iex(2) Example.run(:with, %{user: "Jason", action: "read"})
{:error, "User Jason not found"}

Now we the get same specific errors as the case statement and we didn't need to modify the return values. 😀🎉

Top comments (0)