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 nocatch
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
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)
.
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
In Promise-land there’s only one abstraction for errors: Rejection.
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
I’ve thought about this problem for a long time, but couldn’t find much on the topic. Recently I came up with the perfect search query: “javascript async await without exception for control flow”, which took me to How to write async await without try-catch blocks in Javascript (d’oh).
Returning an error-first pair
Grossman proposes to convert Promises into an error-first pair (much like callbacks do). He’s written a library called await-to-js.
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]
One way is actually not to use await-to-js. Simply have async functions return the [err, value]
pair. That means using the first argument of [err, value]
for Business Logic Errors and leaving Promise Rejections solely for Runtime Exceptions.
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.
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
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.
Async/await is just syntactic sugar for calling .then()
It seems the way JavaScript knows whether something is a “thenable” or not is through duck-typing inferring. Which means you can await
anything with a then
method. Let me repeat that:
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 thenify
to add a then
method to Futures, which would fork it accordingly, just for the sake of await
ing.
However, since you can only await
inside of async function
s, 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!
Generators are the foundation of async/await, so there should be a way to use them to implement “proper” do-notation.
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
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.
Thank you.