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

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.

Throwing Exceptions is close to a “goto” in JavaScript—then there are control flow labels.

Remember, with great power comes great responsibility. You wouldn’t use goto in your code, so don’t use throw for Control Flow. Here’s a simplistic example.

The Exception Entanglement Problem

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

Callbacks don’t need fixing

  • Runtime Exceptions are caught by try/catch.
  • Business Logic Errors are reported with the first argument of the callback-function — cb(err, null).

Callback-style programming has separate abstractions for handling Runtime Exceptions and Business Logic Errors. A simple proof is that when an Exception is thrown, the callback never gets called.

The problem lies with Promises

Your Promise may explicitly Reject — to signal a Business Logic Error. However, eventual Runtime Exceptions are coerced into Rejections as well.

The way Promises were implemented eliminated the ability to differently handle Runtime Exceptions and Business Logic Errors. We’ve entangled those and that’s how complexity is born.

The Promise abstraction robs you of the ability to handle different things differently.

Today Promises are universal. The JavaScript ecosystem was thirsty for a solution to Callback Hell and the Pyramid of Doom, and it drove developers to eagerly jump on the Promised bandwagon.

Solution #1: Fixing (broken) Promises

Returning an error-first pair

The error-first pair might remind you of callbacks, but it is totally not the same thing. Actually, the end result looks similar to golang error handling.

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.

So how can we solve this? There are two ways.

Alternative #1: Ditch Rejections and always return [err, value]

Pseudo-code.

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

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.

Here’s what using go-for-it looks like.

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

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?

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?

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.

Async/await is just syntactic sugar for calling .then()

You can `await` anything with a then() method.

(You can await anything really. For example, await 'foo' wraps 'foo' in a Promise and immediately resolves it, but that is less useful to us.)

How can we make use of it? You could write a function called thenifyto add a thenmethod to Futures, which would fork it accordingly, just for the sake of await ing.

However, since you can only await inside of async functions, the output would still be a Promise, not a Future. This means the codebase would still be Promise-based.

Differently put, it means you can only use this trick once — dead end.

Let us implement do-notation with generators!

A year ago I’ve experimented with refactoring tj/co to use data.Task instead of Promises, and there are other several people who’ve tried to add do-notation to JavaScript as well.

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.

Do-notation in JavaScript is awesome.

Interop with Promise-returning libraries 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

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

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

Thank you.

5.649m — Cerro Toco, Chile.

Software that gets jobs done — gunargessner.com