How to handle error handling boilerplate

I personally am very inclined to force explicitness and use more verbosity to describe flows. When using with I normally resort to tagging the branches, but sometimes you do need to pass through the underlying returns from the failing branch. When writing API endpoints I’ve settled in just having a %Response{}/%Output{} struct with :errors, :notices, :payload fields that the frontend knows how to interpret - this approach requires you to do it almost from the beginning and be in control also of how the front-end interacts. It’s also not completely feasible for html pipelines, as you can have redirects and so on, but could probably be made so.

Controllers/channels call only into context functions, and these entry points in the contexts either return those payload structs, or they return normal results and I have a translation module/functions for the returns. Again, it’s more boilerplaty.

But the issue that I think deserves a bit more thought is not that raising exceptions in themselves is bad (outside of things that really require raising, like a db disconnect, or inaccessibility of a resource, or inside a loop/recursion that no longer makes sense, but usually there you use throw), when you look at the flow of a single entry point, it can usually look clearer and in a way less verbose. The problem comes when all your code base applies this style. Then it basically becomes a mine field because everything can raise somewhere and you’re left having to keep in mind every detail of all functions you’re calling and what those themselves are calling.

When I have too much time to think about this nonsense I imagine I would like to see a variation of with (a special form named weave or steps) that would look like this:

weave # notice the new line 
   [not_found: %User{} = user] <- Db.Repo.get(User, user_id),
   [not_authorized: true] <- Contexts.Authorization.is_authorized?(user, some_action),
   [changeset: {:ok, %User{} = new_user}] <- Contexts.Users.update(user, some_action)
do
   {:ok, user}
else
  [changeset: changeset] -> {:error, changeset}
  [{error, _}] -> {:error, error}
end

Perhaps it could even be that when the do block was ommitted it would pass the last value, without the keyword.

weave 
   [not_found: %User{} = user] <- Db.Repo.get(User, user_id),
   [not_authorized: true] <- Contexts.Authorization.is_authorized?(user, some_action),
   [changeset: {:ok, %User{} = new_user}] <- Contexts.Users.update(user, some_action)
else
  [changeset: changeset] -> {:error, changeset}
  [{error, _}] -> {:error, error}
end

This can be confusing the first time you encounter it (not_authorized: true) but afterwards it’s simple to understand.

Other option would be:

weave
  %User{} = user <- Db.Repo.get(User, user_id) -> :not_found,
  true <- Contexts.Authorization.is_authorized?(user, some_action) -> :not_authorized,
  {:ok, %User{} = new_user} <- Contexts.Users.update(user, some_action) -> :changeset
else
  [changeset: changeset] -> {:error, changeset}
  [{error, _}] -> {:error, error}
end