Pattern matching on `else` clause of `with` chain, based on which `with` clause failed

Based on discussion in #elixir Slack…

Imagine a function like this:

def process(params) do
  with {:ok, normalized} <- normalize(params),
       {:ok, validated} <- validate(normalized) do

       do_something(validated)
  else
    {:error, params} -> # when coming from normalize/1
      handle_normalize_error(params)
    {:error, params} -> # when coming from validate/1
      handle_validate_error(params)
  end

  defp normalize(params) do
    # either {:ok, a_map} or {:error, a_string}
  end

  defp validate(params) do
    # either {:ok, a_map} or {:error, a_string}
  end
end

Of course, above code doesn’t properly work, because both above pattern matches have same signature.

In above example, both normalize/1 and validate/1 have same method signature, yet when I handle errors, I need different code to handle them, so I need to know which particular with clause failed. Based on discussion in Slack, there doesn’t seem a current way to do that, except to wrap normalize/1 and validate/ in functions which change the return signature from {:error, a_string} to {:error, :normalize, a_string} or {:normalize_error, a_string}.

It feels like extra work, especially when using generic functions like Repo.insert, which standardize on {:error, object} as return signature. Before using with, I had the code written in a Phoenix controller and used Plugs to control each step (if a step failed, I rendered error and halted, so next plug wouldn’t be called. But later I extracted controller code into a separate module to uncouple it from connection, so I no longer can use Plugs. I was told to use with syntax instead of plugs, but it seems to have above shortcoming.

Here’s (hypothetical) syntax I wish I could use, not sure if actually possible to implement:

def process(params) do
  with {:ok, normalized} <- normalize(params), :normalize,
       {:ok, validated} <- validate(normalized), :validate do

       do_something(validated)
  else
    {:error, params} with :normalize ->
      handle_normalize_error(params)
    {:error, params} with :validate ->
      handle_validate_error(params)
  end
end

Totally not sure if with keyword in the else clauses is right thing to use here, perhaps instead this could be from, but either way, somehow to annotate which with clause failed, and be able to pattern match on that annotation in the else clause.

Has anyone else encountered this situation? If you ever extracted code from Controller plugs to separate objects using with syntax to conditionally chain a pipeline, you may have seen something similar.

Any feedback appreciated.

Thanks!

1 Like

My gut reaction is that if you need to perform different error handling on different steps, then that needs to happen at the step level and not in a with. I view with as creating a context in which any errors are generally handled the same way. Maybe there’s a some variation in how to report some message back to a user, but the gist is “hey, user, it didn’t work and here’s the best description of why I got”.

I think the functionality you’re looking for is in the happy library, using the tags feature. @OvermindDL1 has the most experience with the library and will extol its benefits!

I would say that you could easily manually do what the tag macro does in that link, which is to say:

with(
  {:foo, {:ok, x}} <- {:foo, foo()},
  {:bar, {:ok, y}} <- {:bar, bar(x)}
) do
  {:ok, y}
else
  {:foo, {:error, reason}} -> handle_foo()
  {:bar, {:error, reason}} -> handle_bar()
end 

But of course, this would get tiresome if you do it very often.

(Sorry if there are typos/errors…I’m on my phone + OTG).

1 Like

Precisely this! The happy library has that feature baked in, but that is how you can emulate it with normal with. :slight_smile:

1 Like