Someone asked me some time ago to share my thoughts on Async/Await in JavaScript. It is my belief that Async/Await (and to some extent*, Optional Chaining Syntax) is a missed opportunity in the evolution of the language.
Consider the following three programs:
const f = () => foo ?. bar ?. baz;
const nullableResult = f();
const f = async () => { return await (await (await foo).bar).baz }
const thennableResult = f();
const f = () => foo.flatMap(x => x.bar).flatMap(x => x.baz);
const manyResults = f();
- In the first program, we assume that
foo
is a Nullable object with abar
property, that's a Nullable object with abaz
property, that's a Nullable value. - In the second, we assume that
foo
is a Thenable (also known as Promise) of an object with abar
property, that's a Thenable of an object with abaz
property, that's a Thenable of a value. - In the third, we assume that
foo
is a "Manyable" (also known as Array) of objects withbar
properties, that are Manyables of objects withbaz
properties, that are Manyables of values.
I'm sure you can already see the pattern here. But what if I told you that JavaScript could have evolved so that these three programs would be exactly the same, and work polymorphically on inputs of type Nullable<T>
*, Thenable<T>
, and Manyable<T>
?
type Nullable<T> = T | null;
type Thenable<T> = Promise<T>;
type Manyable<T> = T[];
All that would be needed for that, is a common interface (like the Promise .then
function) that the language syntax could build on (like they did with Async/Await).
In fact, valid implementations of that potential interface already exist. It's just flatMap
. We can also implement flatMap
for Thenable
and for Nullable
:
const Nullable = {
flatMap(nullable, next){
return nullable === null ? null : next(nullable);
}
}
const Thenable = {
flatMap(thenable, next){
return thenable.then(value => next(value));
}
}
const Manyable = {
flatMap(manyable, next){
return manyable.flatMap(value => next(value));
}
}
So we have our three flatMaps that have exactly the same interface.
At this point, we're already able to create a function like the f
we started with, but polymorphic (working on "anythingable" with a flatMap
):
const f = (Somethingable) => (
Somethingable.flatMap(foo, ({bar}) => (
Somethingable.flatMap(bar, ({baz}) => baz)
))
);
ℹ️ Try it out with
nullableResult = f(Nullable)
If we'd associate these functions with the right prototypes, then we wouldn't even need to pass the correct interface implementation:
Promise.prototype.flatMap = function(next){
return Thenable.flatMap(this, next);
}
// f now works on Arrays, and on Promises:
const f = () => foo.flatMap(x => x.bar).flatMap(x => x.baz);
To some extent*, JavaScript could have adopted this approach, and given us a single syntax for flatMap
that would be able to work with anything that's flat-mappable:
const foo = Promise.resolve({
bar: Promise.resolve({ baz: Promise.resolve("hello") })
});
foo ?. bar ?. baz
//=> Promise<"hello">
const foo = [{ bar: [{ baz: ["hello"] }] }];
foo ?. bar ?. baz
//=> [ "hello" ]
Or, like a more async-awaitey one:
const f = flattening () => {
const a = flatten foo;
const b = flatten a.bar;
return flatten b.baz;
}
Take a moment to let that sink in. This version of Async/Await looks exactly the same, but works on anything that's flattenable, not just Promises. It could have even integrated with custom types that have flatMap
functions, like Observables, Iterables, Tasks, and whatnot.
Instead, we're slowly getting a new special syntax for every new flatmappable type that's introduced to the language, in a process that's unfolding over the course of years, if not decades 😞
(This was one of the purposes of the Fantasy Land specification, to define common interfaces for generalized operations that could benefit from language-syntax-support.)
* The extent to which this is possible with Nullable is very limited, due to the nature of Nullable values.
While Arrays and Promises are explicit wrappers around a value, any value can potentially be null implicitly, so you wouldn't know what to do when flat-mapping over a Nullable Promise, for example.
To get around this, modern languages have a Nullable type (typically called Option) that wraps a value, as opposed to living alongside it.
This somewhat validates the existence of Optional Chaining syntax, although I would've preferred seeing a
flatMap
syntax (and an Option type), over a commitment to the problematic Nullable type through the succinct Optional Chaining syntax.
Top comments (4)
Talking special syntaxes as you say, this is the "pattern" I've been using & abusing so many times to either, filter and flatMap undefineds/nulls/values/arrays... people keep asking me why do I have to overcomplicate things.
You can totally use an Array in JavaScript to approximate an Option type. Since Option is just "zero or one value", if you stick to a maximum of one value in your Array, you have yourself an Option. Your
concat
trick is essentially (almost) anoptionFromNullable
function.In this example I chose to use a slightly different implementation of fromNullable, because the fact that concat does something different on Arrays is hazardous (our fromNullable would break on Array inputs).
The example shows that we can easily (ab)use Array to do our optional chaining with
flatMap
, and we getfilter
andmap
and everything else as a bonus.Now if only
?.
would have dispatched to flatMap, this might have become a standard way of working:Not sure what you mean, can you expand on this one a little bit, please?
Ah, so the idea was to make
?.bar
act like some sort of pluck on an array?I wonder if, with a native
Option<T>
|Maybe<T>
ADT embedded in the language, some choices might have taken a different turn here.Manyable, also known as Array, is already present in the language, and the same goes for Thenable. Both naturally share the flatMap interface. In fact, we would likely have invented it ourselves—it just comes naturally.
If you don't use a container for Nullable, it creates a disjunction that, in this case, leads to optional chaining, in my humble opinion.
Great article, by the way.