Async Control Flow without Exceptions nor Monads
Async Control Flow using Exceptions
throw: Execution of the current function will stop […], and control will be passed to the first
catchblock in the call stack. If no
catchblock 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
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
Callbacks don’t need fixing
Callback-style programming has two separate error abstractions.
- Runtime Exceptions are caught by
- Business Logic Errors are reported with the first argument of the callback-function —
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.
Solution #1: Fixing (broken) Promises
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
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.
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.
async/awaitis 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()
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.)
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!
Generators are the foundation of async/await, so there should be a way to use them to implement “proper” do-notation.
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.
Personally, I would go for Clojure — I’ve had a crush on it for a while now.
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.