Finer control in `with` failed matches

Elixir gives us the with construct to combine matching clauses, which is very handy as programs frequently to perform an operation only if/when a set of preconditions is met.

Let’s say we want to charge a product to a credit card:

with {:ok, validated_card} <- validate_cc(card),
      {:ok, amount} <- apply_sales_tax(item_price, :base),
      {:ok, amount} <- apply_sales_tax(item_price, :luxury),
      {:ok, transaction_id} <- charge_card(validated_card, amount) do
  {:ok, %{id: transaction_id, card: validated_card, amount: amount}}
else
  _ -> {:error, "unable to charge credit card"}
end

We’ve got a few steps we need to succeed, and if any one of those steps fail, we return an error. Unfortunately, as our code stands, the calling code won’t be able to differentiate what caused the error. Maybe it would ask the customer to enter credit card details again if the card validation failed, or a support ticket would be logged with the credit card processing if the card couldn’t be charged? Too bad: you can’t decide what to do without first knowing what went wrong.

But take a closer look at line 7: we’re pattern matching there just like we would in a case. So we should be able to pattern match on the returned errors, right? Unfortunately, all 3 calls on lines 1-4 just return :error if they’re not successful. Foiled again!

But maybe, just maybe, we could tag these statements? That would allow us to determine exactly what step failed even in cases where we’re calling the same function several times (like on lines 2-3). Let’s give it a go:

with {:validation, {:ok, validated_card}} <- {:validation, validate_cc(card)},
      {:base_tax, {:ok, amount}} <- {:base_tax, apply_sales_tax(item_price, :base)},
      {:luxury_tax, {:ok, amount}} <- {:luxury_tax, apply_sales_tax(item_price, :luxury)},
      {:transaction, {:ok, transaction_id}} <- {:transaction, charge_card(validated_card, amount)} do
  {:ok, %{id: transaction_id, card: validated_card, amount: amount}}
else
  {:validation, _} -> {:error, :invalid_card}
  {:base_tax, _} -> {:error, :base_tax_failed}
  {:luxury_tax, _} -> {:error, :luxury_tax_failed}
  {:transaction, _} -> {:error, :transaction_failed}
end

Now, the calling code can know exactly which step failed and respond appropriately. Or if it doesn’t case, it can simply match on {:error, _} and handle all failures the same. And of course, it could handle only a subset of the errors (e.g. invalid cards) and just use the _ match to have a catch all case for handling other errors. The point is: the power is in the caller’s hands, where it should be.

Of course, don’t forget that you can choose to tag some result values, but not others. And of course, you could tag several lines with the same atom:

with {:validation, {:ok, validated_card}} <- {:validation, validate_cc(card)},
      {:tax, {:ok, amount}} <- {:tax, apply_sales_tax(item_price, :base)},
      {:tax, {:ok, amount}} <- {:tax, apply_sales_tax(item_price, :luxury)},
      {:ok, transaction_id} <- charge_card(validated_card, amount) do
  {:ok, %{id: transaction_id, card: validated_card, amount: amount}}
else
  {:validation, _} -> {:error, :invalid_card}
  {:tax, _} -> {:error, :tax_failed}
  _ -> {:error, :unknown}
end

I hope you’ll find this concept of tagging useful. In fact, you might come across it in other places, such as in Ecto.Multi.

This entry was posted in Elixir, Pattern snippets. Bookmark the permalink.