Explicit exceptions - a solution to fragile code dealing with exceptions

The problem we're dealing with

Do you buy the argument that exceptions should be part of the function's API? i.e. they're stable - if the function stops throwing an exception or throws a different exception instead, then it's a breaking change. Otherwise, how can anyone rely on them? The only thing you could do is treat them as an error and simply report the failure without trying to recover.

With that in mind, here's why Javascript's current system of invisibly auto-propagating exceptions is bad and fragile. (It works great for errors, which is what it was designed for, but it's horrible for exceptions)

  1. It's near impossible to figure out what exceptions a particular function might throw. How can you keep the API stable if you don't even know what the API is?
  2. It's difficult to refactor code without making breaking changes. You want to make a public-facing function stop using function f internally, and use g instead, as it better suits what you're trying to achieve. Because it's difficult to know what exceptions f() and g() throw, it will be difficult to know if this change will be a breaking change or not.
  3. They're difficult to contain and control. Throwing a new exception deep down in the low levels of your project can cause who knows how many of your public-facing functions to start throwing that new exception. The public will stumble into these new exceptions and start relying on them because it's reasonable to expect that an exception with a distinguishing code is supposed to be able to be singled out, caught, and handled. You've accidentally added exceptions to your API (not a breaking change) and can't remove them without it being considered a breaking change. As an example: Maybe you have an internal-use-only exception "BadResponseBody" that was never intended to be used by the public. You send the response body of a REST request to a parseResponse function, which might throw BadResponseBody, and you forgot to escalate it in this specific scenario (isn't invisible auto-propagation great? This is easy to forget). Now your public getStuffFromServer() function could start throwing a BadResponseBody exception whenever it connects to a misconfigured external server, and your end-users might start relying on this exception to detect a misconfigured server.

Hopefully, it's clear that if exceptions are part of our API, then these three points are big issues with invisible, auto-propagation of exceptions. There are many ways to fix these issues, some of which have been discussed in this thread as alternatives to my proposal. All of these solutions have different downsides, so it's a question of which one works the best in Javascript. Java solves this issue with checked exceptions (turning invisible, auto-propagation to mostly visible, auto-propagation), Rust and other functional languages have "either monads" (many people love this system, some find it too verbose outside the functional world), and then there's my proposal.

My proposed solution

The way my proposal solves this issue is by making auto-escalation the default behavior for exceptions, instead of auto-propagation. If you want to propagate an exception, you have to explicitly do so with the excepts keyword. (meaning even if you throw a NotFound exception in one function, it'll be received as a generic Error instance by its caller, unless its existence was explicitly recognized with the "excepts" keyword). Any exception you do not wish to handle or propagate should simply be omitted from the exceptions list.

Observe how neatly it solves the three issues I brought up about Javascript's invisible auto-propagation:

  1. It's not that difficult to figure out which exceptions a particular function can throw. Any exception it can throw must be explicitly named somewhere in the function body.
  2. It's very easy to refactor your higher-level code without making breaking changes. If you want to swap f() for g(), you know exactly which exceptions were being propagated from f() (it's in the "excepts" clause), and you can ensure that you propagate the same exceptions from g(). If g() throws any extra exceptions, you can be sure to leave them out of the excepts clause, so they auto-escalate and don't change your API (unless you want to start supporting these extra exceptions in your public API). If g() doesn't throw enough exceptions, then you may have to make some adjustments to make sure all exceptions are accounted for.
  3. They're easy to contain and control. If you decide to change what used to be an error into a new GoneFishingTryAgainNextWeek() exception deep down in your codebase, it won't change a thing unless you've explicitly acknowledged the existence of the exception. Without acknowledging its existence, its default behavior is to escalate it into an error, and since it used to be an error anyways, no public APIs will change (unless you explicitly change them by acknowledging the existence of this exception).

Responses to your concerns

The reason is because we're changing the default behavior of exceptions to auto-escalate instead of auto-propagate, so it makes sense to use the excepts keyword outside a try block. This is the only way to propagate it, and it's designed this way because invisible auto-propagation causes a lot of problems.

It does care what getUser() throws. It specifically cares about the fact that getUser throws both an Unauthorized and NotFound exception, and it doesn't care about anything else. The purpose of this "excepts" clause wasn't to propagate all the exceptions that getUser() throws, it was to specifically propagate just those two, which happen to also be the only two that getUser throws. But if getUser decided one day to start throwing GoneFishingTryAgainNextWeek too, then lucky for us, it won't be in our excepts clause, so it will be auto-escalated. No API change will happen in this function without us explicitly changing this function. We won't accidentally start exposing a new exception as part of our public API (if this were a publicly exposed function, or used by one).

Some final notes

I don't want to claim that my proposal should be the solution to this problem. I do like it, and I do think it solves a number of problems very well, but I also understand it has some shortcomings, and know that people might find more comfort in a familiar checked-exception way of solving this issue (like @clemdz proposed). Something needs to fix the broken exception system, and I don't know what that fix will be yet.

I also want to clarify something thing that I never said, but should have. I don't know if you were looking into the proposal-comparison repo or not, but I intended all of the functions (in both userManagement.js and exampleUsage.js) to be seen as part of the same project. If functions such as getUser() were part of a third-party API, then I would write the exception-handling code differently from what's being shown in that repo (by translating the exceptions from one domain to the next).

As one final thing - thanks for this conversation - it's helped me better articulate what the current problems are in Javascript and what it is we're actually trying to solve. Those three points I listed above are the issues with Javascript's exception system that needs to be fixed, one way or another. Some other points I've talked about previously might better be categorized as nice-to-have ideas in a new exception system, but it isn't the specific problem we're solving. It's good for a thread to have a focus, and in this case, I think a good focus is "how to best solve those three issues in Javascript".