Async Control Flow without Exceptions nor Monads

tldr; Don’t mix up Runtime Exceptions with Business Logic Errors. Promises are flawed. You can fix them with go-for-it, or you can replace them for the Future Monad + fantasydo.

Async Control Flow using Exceptions

The throw keyword is one of the most powerful features of the JavaScript language. It provides the one-and-only way to jump across multiple Call Stack Layers.

throw: Execution of the current function will stop […], and control will be passed to the first catch block in the call stack. If no catch block exists among caller functions, the program will terminate. (Source)

Given its immense power it’s tempting to overuse it—for Control Flow and Cross-Layer Messaging.

The Exception Entanglement Problem

Overusing throw is a code smell because it’s likely that you will end up entangling Runtime Exceptions and Business Logic Errors. Merging them just pushes complexity downstream. Here’s what Exception Entanglement looks like.

It took me a long time to understand why it is useful to make the distinction. Not sure I can convey my insight in a few sentences, but I’ll try. Think it like this:

Given two categories of things, would you rather have the ability to know the difference between them or not?

Merging two categories is easy — if that’s what you want. Splitting them back up is hard. Aim for simple, not easy.

Synchronous code is easy to fix

Just don’t use throw for Control Flow — use return only.

Callbacks don’t need fixing

Callback-style programming has two separate error abstractions.

Runtime Exceptions are caught by try/catch.

Business Logic Errors are reported with the first argument of the callback-function — cb(err, null).

There is, however, one persisting problem. Grossman’s approach does not tackle Exception Entanglement. Using an error-first pair allows you to get rid of the try/catch, but that only solves half of the problem. As you can see, Runtime Exceptions still get mixed up with Business Logic Errors — because Promises coerce them.

Should all our async functions return an error-first pair? Maybe. In your own codebase that is an option, but you can’t make the whole ecosystem follow this pattern— and it has long decided to use Promise Rejections.

Alternative #2: Filter native Error classes

How can we solve the remaining second half of the problem, while still being able to consume libraries from npm?

Since we can not filter for Business Logic Errors (there’s an infinite number of them), why don’t we just do the opposite and filter out Runtime Exceptions?

This is what the package go-for-it attempts to do. It lets native Exception classes pass through (e.g. EvalError, RangeError, ReferenceError, SyntaxError, URIError), but otherwise catches and pushes errors into an error-first pair.

This approach is still Exception-based — as it makes use of throw. However, Rejections wrapped in go-for-it can’t jump more than one Stack Frame anymore — effectively working like a return instead of a goto.

Solution #2: The Functional Approach

The Promise abstraction provides both an easy API to defer a computation until a later time and the ability to create computational chains — nothing that can’t be done in user space with a library.

Once in user space, why not go straight for “proper” Monads? We could handle synchronous execution with Sanctuary’s Either Monad and asynchronous execution with Fluture (replacing Promises).

The title of this article says “without Monads”, I know… but bear with me for a while. Let’s do a quick investigation.

The Future Monad allows you to do Control Flow without Exception Entanglement. However, it has the same problem as any new abstraction — it spreads over your codebase like a virus. Once you write Monad–returning functions, all consumers need to understand that Monad.

Transitioning from Callbacks to Promises brought up the same issue back then. Previously written Callback-style code could be used with new Promise-based code, but needed to be “promisified”. Nowadays Promises are mostly universal and we don’t need to do that anymore.

So don’t we “futurify” Promise-based packages on-the-fly?

Because you can’t await “proper” Monads — just Promises.

In occasional circumstances it make sense to create computational chains with .then(), but most of the time async/await accomplishes the same thing but with better readability.

Giving up async/await is a scary thought.

What is `await` anyway?

When you think of it, the await keyword is nothing more than a Promise-specific do-notation. It makes sense it has been implemented this way, since ECMAScript was already committed to the Promise abstraction.

If ECMAScript had implemented “proper” Monads, we could have “proper” do-notation by now.

Note that I am not saying it would have been better — just saying it would have been different. But that didn’t happen. No point in spending energy trying to curb reality into idealistic scenarios — that’d be insane.

It turns out it is possible to make it generic — so that it works with any Monad, not just data.Task. There are a few implementations out there but I like fantasydo the most. It complies with Fantasy Land Spec, which means it can interop with a variety of algebraic libraries.

Interop with Promise-returning libraries from npm

Monads don’t natively coerce Exceptions into Rejections — so no Exception Entanglement problem here. But what would happen when you consume Promise-returning packages from npm?

We would have to choose between treating all Promise Rejections either as Future Rejections or as Runtime Exceptions — Exception Entanglement attacks again.

This dilemma should be solved on a per-case basis, but generally we would expect libraries to never throw Runtime Exceptions — we expect them to be thoroughly tested. Ultimately, you can always use go-for-it to try and lessen the damage.

I am not entirely sure how that would look like, though.

(Non)-solution #3: Quit JavaScript

Researching for the present article gave me an even greater insight into what JavaScript’s strong and weak features are. We can take advantage of JS’s extensive compatibility without having to write actual JavaScript code.

Before learning about fantasydo I was going to propose creating a transpiler to add do-notation to JavaScript. However, if you’re willing to go that far, just use a different language that compiles to JS.

Personally, I would go for Clojure — I’ve had a crush on it for a while now.

Afterword

I can’t begin to tell you how excited I am! Giving up on async/await was never an option for me, but now that I’ve found do-notation for JS it is not a problem anymore.

This unlocks a gigantic field of learning, which I am eager to delve into.