I often feel like I’m abusing {ok, Result} | {error, term()} pattern. I find it very convenient in most cases, especially when I combine few calls within maybe expression, but every time I see API-s that throw errors instead of returning tuples, I ask myself why.
So the question is, which pattern do you prefer and why? Is there any rule-of-thumb?
The {ok, Result} | {error, term()} pattern is idiomatic Erlang. That doesn’t make it either right or wrong for any specific use case, but it does respect the principle of least surprise.
I think a rule of thumb can be does your function have side effects that maybe the caller want to somehow handle or is your function a pure function that always will succeed if it gets good input. The first should return {error, Reason} and the second one should make a runtime exception. And then you have throw which is sort of in-between that you should always catch an probably give a better Reason. Now we might not always do this consistently in OTP for lots of legacy or even efficiency reasons. But that would be my suggestion.
I personally prefer {ok, Result} | {error, term()} over throwing exceptions, but only for what I’d call non-type related errors. If someone passes you the wrong type, feel free to badarg or badmatch or whatever. If the input somehow doesn’t honour state or logic requirements, and someone may need to act on that error, then I strongly prefer {error, SuitableDescription}. I’ve seen far too much code catching exceptions and turning them into {error, _} returns.
This is exactly the rule I use. If the caller can meaningfully handle the various cases at the call site a structured reply should be returned. ({ok, Result} | {error, Reason}, or any other term structure that can be distinguished really). If the caller cannot make any decision or fix the error by additional code, an exception is warranted.
In my opinion there is an anti-pattern of using error tuples to transport errors through many levels of code. That increases the code complexity a lot and can lead to tons of nested case statements whose only purpose is to move error tuples somewhere else (maybe makes this a little bit better, but still forces you to wrap all calls in maybe statements instead of case statements).
In production code I’m working on now for example, one class of errors are database connection errors. The business logic code which is structured throughout many modules often calling each other cannot possibly do anything to fix these errors when they occur, so in our system it is an exception. Then we have a top level request handler in the HTTP API that catches these and return 503 Service Unavailable. The code would look much messier if this was an error tuple.
I throw internally if I want to handle control back to a controller layer. Exceptions prevent error handling at callers. In all other case the pattern is great.
BTW, exceptions are even nice to prevent deep case-of/if statements. Just catch the throw (Code) exception type at the end of the function and return {error, Code}.
This was one of the topics I was quite confused by when I started with Erlang. I am still not very consistent in my approach to error handling, and it would have helped me tremendously if this topic had been addressed in more depth in the Erlang documentation. In fact, there should be a special chapter devoted to error handling in OTP Design Priciples and the Getting Started guides.
To my regret, I do not consider myself sufficiently competent to adequately document something like this and hope someone else might be able to do so.
I agree with you on that, but I think pure API functions in general should cause runtime errors if they are called with invalid arguments, but not a throw exception.
Absolutely, I put lots of guards on my outer API functions so that they get a badarg unless it’s correct. Sure, don’t program defensively, unless you’re dealing with the outside world, and a library user is that.
I think it also very much depends on what type of error it is and how/where it should be handled. A simple example is file:open/2. If you can’t open the file for some reason then you get an {error,Reason} return while if you give a wrongly typed argument then it will throw an error. The first is a type of error you definitely want to handle in the code, typically inside a case, while the second is definitely a coding error which should be handled in an exception.
Always throwing an error for all errors will result in an enormous amount of try/catch which will clutter the code and make it harder to read. Of course what will happen is there will be a lot of ?CASE macros defined to do the wrapping to make it look like a case.
I have never once seen a codebase with too many nested try statements, but I have definitely seen many codebases with too many nested case statements.
The symptoms of the latter are nested case statements spread out through many modules and functions who’s sole purpose is to transport some error from the lowest layers to the highest layers. In my opinion such cases are a better fit for exceptions.
Go’s explicit error had long suffers similar problems in that it gets hard to see the forest for all the trees when errors have to be handled at every layer.
This can sometimes unnecessarily obfuscate the business logic, when the errors are not ”switchable” (i.e. they can’t be handled at the call site in a sensible fashion).