How to ensure {:error, _} tuples are handled and not silently ignored?

First, sorry if this has already been asked in other topics. I couldn’t find it, but I would guess it’s a common thing to handle.

Let’s say I create a function that returns :ok or {:error, cause} — it’s a function that focuses on side effects. If I forget to handle the error (match the return value), the error will effectively be swallowed silently. How do you remember to handle the error? Or is this a bad pattern? Maybe the function should just raise an error instead?

It would be nice to catch this at compile time, but I understand it’s hard to do since Elixir is dynamic. However, Elixir is gradually becoming more statically typed. Will it be possible (or is it already possible) to check this statically?

1 Like

If your program should crash you can assert like you suggested or you write a ! version that raises.

3 Likes

The idea behind such a return pattern is that the function doesn’t want to decide how errors are to be handled, but delegates this responsibility to the caller of the function. So as you eluded to it’s the responsibility of the caller to deal with handling errors.

If you just do my_function() then indeed it could succeed or fail and nobody will be informed of either. Personally I’d argue ignoring return values is most often a code smell. It should likely be :ok = my_function() to raise in any non successful cases, or you could use case or with to deal with the error somehow.

There’s also a setting for dialyzer to warn if return values are not matched on forcing _ = my_function() for the cases, where indeed the return value is to be ignored.

13 Likes

I typically have a lot of function chains like this:

def my_function do
  with {:ok, some_value} <- some_function(),
       {:ok, some_other_value} <- yet_another_function(some_value) do
    # Return a tuple with some data, or just `:ok` if there's no data to return
    result = do_some_stuff_here(some_other_value)

    {:ok, result}
  else
    {:error, :some_expected_error} = result ->
      Logger.error("Got a specific type of expected failure while calling my function.")

      result

    result ->
      Logger.error("Got an unexpected error while calling my function.")

      result
  end
end

This way, your successes bubble up the chain, and your errors get gracefully halted at the first point where they fail, and you can log extra info, or handle specific errors as needed.

FYI, if you don’t need the extra error handling, you can exclude else from the with statement, and it will return the first error, if there is one:

  with {:ok, some_value} <- some_function(),
       {:ok, some_other_value} <- yet_another_function(some_value) do
    # Return a tuple with some data, or just `:ok` if there's no data to return
    result = do_some_stuff_here(some_other_value)

    {:ok, result}
  end
3 Likes

Yeah, this 100%. Elixir is not a statically-typed language and we can’t use sum types either.

Best we can do is make sure the code crashes early.

One very curious psychological effect of this is that people suddenly become good programmers again: “We can’t just assume a successful result!” – same people who were perfectly OK with just ignoring the return value.

So I found :ok = do_stuff() an amazing psychological hack: you make people pay attention and they also want proper error handling.

6 Likes

See also non-assertive pattern matching in the anti-patterns docs, which expresses a sentiment similar to the above replies.

3 Likes

Thanks for your answer!

Unfortunately, this is not what I wanted to hear. I feel it’s too easy to forget to match against a return value, which can result in a silent error. Even though I’ve already come to the conclusion that ignoring return values is an anti-pattern, I still need to remember to check them. But I’ll try dialyzer and see if it can help me, as you suggest.

I wanted to edit the post above, but couldn’t find a way to do it.

I wanted to remove “Unfortunately, this is not what I wanted to hear.” because it’s not clear what I mean. I was a bit disappointed, because even though the answer was a good one, the problem still remain: it’s really easy to forget handling the error. I think it’s a footgun.

I think it’s a footgun.

I am by no means an expert, but I’d say this concern has already been answered: returning :ok or {:error, reason} means: I’m expecting the caller to handle this. This is intentional.

You can always write my_fun first, and then its ! close to it:

# warn: pseudocode
def my_fun!() do
  case my_fun() do
    :ok -> :ok
    {:error, reason} -> raise reason
  end
end

Then, it’s up to the caller to decide what to call, and how to handle it.

Another argument could be that a caller could always mess it up by using a try clause and unintentionally catch that specific error, while handling others. So, if we want to bring up “bad examples”, I think our imagination could be unbounded :smiley: But that doesn’t mean that monads (or exceptions) are a bad pattern per se. If anything, Elixir makes it explicit: if there’s a !, expect and exception.

1 Like