Replacing async/await in 8 lines of code
There’s a snippet I write almost every year. I don’t write it for use in production code, but I do write it to make a point.
JavaScript would be better off without async/await
This may seem a blasphemous opinion, but hear me out. It’s a very simple snippet.
function make({ pure, bind }) {
const next = gen => input => {
const { value, done } = gen.next (input)
if (done) return pure (value)
return bind (value) (next (gen))
}
return (gen) => next (gen ()) ()
}
These 8 lines of code can be used to replace most usages of async/await. Don’t believe me? Try it out
Promise.do = make ({
pure: x => Promise.resolve (x),
bind: x => f => x.then (f)
})
Promise.do (function* () {
const x = yield Promise.resolve (3)
const y = yield Promise.resolve (4)
return x + y
})
.then (console.log) // logs 7
Notice that we’ve just made an async/await equivalent in 8 lines of code, using language features that have existed since before async/await. the thing is this snippet is actually more versatile than async/await.
Imagine for a moment if you need to add two nullable numbers. One way to do this is to check for null values
function addNullables(x, y) {
if (x == null) return null
if (y == null) return null
return x + y
}
What if we could use the same code for promises and nullables?
const addM = x => y => function* () { return (yield x) + (yield y) }
const Option = {
pure: x => x,
bind: x => f => x == null ? null : f (x),
}
Option.do = make (Option)
console.log(Option.do (addM (3) (4))) // 7
console.log(Option.do (addM (null) (4))) // null
Promise.do
(addM (Promise.resolve (3)) (Promise.resolve (4)))
.then (console.log) // logs 7
You can see the full example as a flems here
When I write this snippet I imagine an alternate timeline where async/await wasn’t special cased and first-classed in JS. It’s a timeline where a snippet much like the one above might live in most projects. It’s a timeline where the JS community is more comfortable with generators.
Now, to be clear, I’m not saying that the snippet is better than async/await. It’s very clearly not. It doesn’t create the same sort of state machine that async/await does under the hood. It doesn’t handle errors the same way, etc.
What I am saying is that I believe the JavaScript community would be better without async/await.
Lazy async
For the fun of it, let’s imagine if Promises didn’t exist and we wanted a simple way to sequence callbacks. Would you believe the snippet can do that?
First, let’s define async as type Async<T> = (callback: (val: T) => void) => void
.
next we’ll need to define the two helpers pure
and bind
.
const Async = {
pure: x => callback => callback (x),
bind: computation => f => callback =>
computation (value => f (value) (callback))
}
Async.do = make (Async)
const computation = Async.do (addM (Async.pure (3)) (Async.pure (4)))
// this style of async is lazy, so nothing is run until the final callback is provided
computation (console.log) // logs 7
const sleep = ms => callback => { setTimeout (callback, ms) }
Async.do (function* () {
yield sleep (300)
return Async.do (addM (Async.pure (3)) (Async.pure (4)))
}) (console.log) // waits 300ms then logs 7
Addendum
There are two things I’d like to point out at the end. Firstly, there’s a name for things that work with the above snippet. That name is Monad, and you don’t have to be afraid of that word. Secondly, the snippet doesn’t work with Monads that contain multiple values such as streams, lists, and arrays. There’s a way to make it work by emulating immutable generators.
const next = regen => data => {
const gen = regen()
gen.next (data)
return gen
}
/* See: https://github.com/pelotom/immutagen/blob/master/src/immutagen.js */
const replayable = regen => {
const loop = regen => (gen, data) => {
const { value, done } = gen.next (data)
if (done) return { value, next: null }
let replay = false
const mutable = () => {
if (replay) return regen (data)
replay = true
return gen
}
const recur = loop (next (regen, data))
return { value, next: value => recur (mutable (), value) }
}
return () => loop (next (regen)) (regen ())
}
const make = ({ pure, bind }) => {
const doNext = getNext => input => {
const { value, next } = getNext (input)
if (!next) return pure (value)
return bind (value) (doNext (next))
}
return gen => doNext (replayable (gen)) ()
}
With this in place we can define a do
for Array
Array.do = make ({
pure: x => [x],
bind: x => f => x.flatMap (f)
})
console.log (Array.do (addM ([1, 2]) ([3,4]))) // [4, 5, 5, 6]
We could also define a do for flyd streams or RxJS’s Observables
flyd.do = make ({
pure: x => flyd.stream (x),
bind: x => f => flyd.chain(f, x)
})
const x$ = flyd.stream()
const y$ = flyd.stream()
flyd.do (addM (x$) (y$))
.map(console.log)
// push some values into the streams to see the logs
// this will log 7,8,9,10
x$(3);y$(4)
x$(4)(5)(6)
A full example flems here