A with that tags each clause's failure by name, so else can tell which
step failed even when two clauses can fail with the same-looking value.
Plain with matches each clause's else value on whatever shape it
happens to have. That falls apart the moment two clauses can produce the
same-looking value — say, validate_email/1 and validate_age/1 both
returning {:error, :invalid} on failure. In else, a pattern like
{:error, :invalid} can't tell you which one actually failed; both
clauses funnel into it identically.
Tagging each clause by name fixes that, and it's not complicated — just
{name, pattern} <- {name, expr}. But it means writing the name twice
per clause, by hand, every time:
with {:email, :ok} <- {:email, validate_email(params)},
{:age, :ok} <- {:age, validate_age(params)} do
:ok
else
{:email, {:error, reason}} -> {:error, {:invalid_email, reason}}
{:age, {:error, reason}} -> {:error, {:invalid_age, reason}}
endtagged_with/2 lets you write the bare pattern you'd use in a plain
with (:ok, {:ok, value}, ...) and the name once per clause; the
macro expands it into exactly the form above, so else can tell the two
{:error, :invalid} results apart even though neither clause's own
return shape does:
import TaggedWith
tagged_with email: :ok <- validate_email(params),
age: :ok <- validate_age(params) do
:ok
else
{:email, {:error, reason}} -> {:error, {:invalid_email, reason}}
{:age, {:error, reason}} -> {:error, {:invalid_age, reason}}
endelse is optional, exactly like in plain with. Without it, a failed
clause's tagged value (e.g. {:email, {:error, reason}}) is returned
directly — no exception, no special no-match behavior, since that's
already how with behaves without else.