`await`ing on a Better Future
A case for code abstractions
Although I was initially satisfied with the introduction of the await
keyword in JavaScript, I’ve come to think that programming languages can completely abstract this concept without any special syntax. In the short term, we should prefer code abstractions of our own to help us forget about async.
Reminder, async allows programmers to do many things at “sort of the same time,” i.e., breaking in the middle of execution to do something else. This becomes especially important if a program has many IO-blocked operations (e.g., calling remote services, accessing the disk, waiting on user input, and so on).
The async/await syntax is a relatively new addition to mainstream programming languages. It allows writing linear code that will not execute linearly.
Typical usage looks something like this:
// Execution will possibly go elsewhere here
const nBoxes = await getNumberOfBoxes();
// Here too.
const nBunnies = await getNumberOfBunnies();
const message = nBoxes - nBunnies > 0
? 'we have enough boxes'
: 'we don\'t have enough boxes';
console.log(message);
The two async calls are done sequentially, but since they have no side effects (they are read-only), they could have occurred at the same time, which would be considerably faster like this:
const [nBoxes, nBunnies] = await Promise.all([
getNumberOfBoxes(),
getNumberOfBunnies()
]);
const message = nBoxes - nBunnies > 0
? 'we have enough boxes'
: 'we don\'t have enough boxes';
console.log(message);
Such inefficiencies are common and are often caught in code review.
Another problem is that imperative coding is done linearly: lines execute one by one in the order they are written. Sometimes, like in this example, we don’t care which line is run first. Still, we have to choose, which introduces arbitrary variation — noise.
Can we do better? Can we reduce the noise and make sure the code runs optimally?
If we look at the graph representation of our programs, then the dependencies between nodes in this graph imply what can be done in parallel (intuitively, anything in the same “layer”).
In this representation, a non-semantic degree of freedom is taken away; we can no longer apply a “line permutation” transformation to the code.
But sometimes managing await
is semantic. Take this case for example:
await createUser(data)
const users = await getUsers()
return users
This would not be equivalent to the following:
const [_, users] = await Promise.all([createUser(data), getUsers()])
return users
The async calls have side effects, which means the order of await
is important even though the dependencies don’t imply it clearly.
But most of the time, letting the programs manage both await
and execution order is a recipe for trouble, or at the very least, some demand more effort. It’s just not the right level of abstraction. We will add these statements to make the code “just work.”
In some sense, async is similar to pointer manipulation. We used to manage memory usage manually. Now, most of us, most of the time, just don’t anymore. It was abstracted away by clever syntax.
So, we should prefer having async be managed for us and not by us. For this, we can use better abstractions for execution: async-aware composers (see implementation below). Using them, the example above would be written like this:
pipe(
juxt(getNumberOfBoxes, getNumberOfBunnies),
subtraction,
greater(0)),
result => console.log(
result
? ’we have enough boxes’
: ’we don\’t have enough boxes’
),
)
pipe
basically means function composition (in a reversed order) and juxt
means composing in parallel, so we input one value and get an array of results.
This way of writing has three advantages:
- It optimally awaits. We never actually do the call
f()
orawait
explicitly ourselves.juxt
andpipe
make smart decisions about that, so there’s one less thing to worry about. - It is denoised because there are fewer ways to write it correctly. The order is more dependency-driven and more semantic — we can’t switch any lines.
- It is less duplicated/correlated with constituents in the sense that changing
getNumberOfBunnies
to be synchronous would not require a code change here.
What about cases where the IO is impactful, and we want to make sure things happen in sequence? We should explicitly aspire to that, so some composer links the actions that need to happen in sequence, not just floating around: a future editor might inadvertently change their order and cause a bug. A doInSequence
abstraction works like this:
return doInSequence(
() => createUser(data),
getUsers
);
These abstractions have in common that they help us think in the dependency space, which is more meaningful than execution-order space (which is at times meaningful and sometimes arbitrary).
This, in turn, reduces noise, is more concise, and is always optimal.
Appendix
const wrapPromise = (x) => Promise.resolve(x);
const doInSequence = (head, ...rest) => wrapPromise(head()).then((x) => (rest.length ? doInSequence(...rest) : x));
const map = (f) => (xs) => {
const results = [];
for (const x of xs) {
results.push(f(x));
}
return results.some(isPromise) ? Promise.all(results) : results;
};
const juxt =
(...fs) =>
(...x) =>
map((f) => f(...x))(fs);
const reduceHelper = (reducer) => (s, xs, firstIndex) =>
firstIndex === xs.length
? s
: isPromise(s)
? s.then((s) =>
reduceHelper(reducer)(reducer(s, xs[firstIndex]), xs, firstIndex + 1),
)
: reduceHelper(reducer)(reducer(s, xs[firstIndex]), xs, firstIndex + 1);
const reduce = (reducer, initial) => (xs) =>
initial
? reduceHelper(reducer)(initial(), xs, 0)
: reduceHelper(reducer)(xs[0], xs, 1);
const pipe =
(...fs) =>
(...x) =>
reduce(
(s, x) => x(s),
() => fs[0](...x),
)(fs.slice(1));
You can find these and others at https://github.com/uriva/gamlajs.