Introducing a "Backcall" operator to address nested code
TLDR
The backcall mechanism allows us to unnest callbacks, promoting linear and intuitive code flow. While async/await
elegantly handles Promises
, backcalling generalises the concept to the other common areas like nested lists and error handling.
Context
JavaScript has historically struggled with the infamous "callback hell".
This issue was most prevalent in asynchronous code, when operations would depend on previous results. This lead to the introduction of Promise
, allowing more linear flow by chaining .then()
methods. However, this was was insufficient in some cases and resulted in the introduction of async/await
syntax.
Nevertheless, deeply nested callbacks are still used extensively in other contexts like arrays, error handling, events and middleware to name a few.
Backcall – A Broader Solution:
Drawing inspiration from Livescript, I propose introducing a "backcall" mechanism to JavaScript via a new operator, tentatively <=<
[op]
The idea is to desugar the following code
const fn = table => {
const row <=< table.map
const col <=< col.map
return doSomething(col)
}
to
const fn = table => {
return table.map(row =>
row.map(col =>
doSomethingCol(col)
)
)
}
Any variable declared in a backcall corresponds to the callback's parameter, and the remaining block statements get wrapped as the callback's body.
This is inline with how async/await
works, without the extra overhard of wrapping everything in a Promise
.
In fact, this syntax would also work with Promises
[wait].
const result <=< request().then
Points of discussion
Multiple callback parameters
In the example above for Array.map
, we're clearly missing the remaining arguments to .map()
One solution could be an extension to allow declaring multiple variables:
const row, index, arr <=< table.map
Alternatively, we can somehow perhaps leverage arguments
?
Callback parameter location
The examples above assume the right-hand side expression is a unary function. That is not always the case and has been a source of great contention in the pipeline operator proposal discussions.
A simple improvement would be to check whether the expression is a callExpression
or not. If it is, then simply add the callback as a last parameter. If not, then use the above behaviour.
const fn = list => {
const elem <=< list.map
const evt <=< elem.on("click")
return doSomething(evt)
}
Would desugar to
const fn = list => {
return list.map(elem =>
elem.on("click", evt =>
doSomething(evt)
)
)
}
Naturally, we still don't have the ability to use this notation with functions that do not have the callback as their last argument.
The pipeline proposal seems to be heading towards introducing a placeholder symbol; using that as a precedent, we could have:
const evt <=< elem.on("click", %)
Further Examples
Error handling:
doSomething(result => {
if(result.status === "error") return handleError(result)
doSomethingElse(result, data => {
if (data.status === "error") return handleError(data)
if (!data) return handleNoData();
return process(data);
});
});
const result <=< doSomething;
if (result.status === "error") return handleError(err);
const data <=< doSomethingElse(result)
if (data.status === "error") return handleError(data)
if (!data) return handleNoData();
return process(data);
Event handling
button.onClick(event => {
handleInitialClick(event, result => {
if (result.shouldProceed) {
setupSecondaryListener(data => {
processData(data);
});
}
});
});
const event <=< button.onClick
const result <=< handleInitialClick(event)
if (!result.ready) return
const data <=< setupSecondaryListener
processData(data);
Express-like middleware
app.use((req, res, next) => {
authenticate(req, authResult => {
if (authResult.isAuthenticated) {
updateSession(req, _ => {
next();
});
} else {
res.send("Not authenticated");
}
});
});
const req, res, next <=< app.use
const authResult <=< authenticate(req)
if (!authResult.isAuthenticated) return res.send("Not authenticated");
const _ <=< updateSession(req)
next()
Synergy with other proposals and beyond
The Haskell link
There have been several proposals to introduce Haskell's monadic "do notation" in some shape or form.
Historically, there's been some attrition as (in)famously, the Promise/A+
spec rejected a more monadic model.
A backcall is not necessarily the same thing, but does provide a lot of the power of "do notation" in terms of code linearity and expressiveness. Crucially, it is compatible with monadic code, if one simply implements a bind
function.
Indeed, in Typescript land, combining this with the Protocols proposal would essentially allow developers to enforce typed monadic do notation.
Comprehensions and LINQ
List comprehensions were popularized by Python, and they can be incredibly expressive. They're present in many languages and, in fact, Haskell's version even influenced the development of LINQ in C#.
The backcall would allow a idiomatic "javascripty" way to express comprehensions.
Take the example of flattening a list of lists
In python:
[x for list in list_of_list for x in list]
which imo, is better structured in Haskell
[x | list <- list_of_list, x <- list]
and perhaps even better in C# using LINQ
from list in listOfList
from x in list
select x;
and now in JS using backcalls
{ list <=< listOfList.flatMap; y <=< list.map; return y }
Ok, granted, it's a ridiculous example because you'd just use flatten()
, but it serves to illustrate the parallelism with those features.
It already looks similar, and that's because there's a well known relation between "do notation" and comprehensions.
By introducing the backcall, we also allow developers to use comprehension-like syntax, which could be an improvement in some situations.
It further opens the door for future improvements like guards, transforms, grouping like in haskell or C#
Conclusion
The introduction of backcall helps address a very prominent issue with nested callbacks, significantly improving code readability and maintainability and providing developers with a consistent and intuitive way to approach both asynchronous code and other callback-heavy patterns.
It further also prepares JavaScript for a future where the language can handle complex operations, custom data structures, and advanced transformations in a way that's both powerful and readable.
Annex
Parallel with async/await
const result1 <=< request1().then
const result2 <=< request2({ result1 }).then
const result3 <=< request3({ result1, result2 }).then
const result4 <=< request4({ result1, result2, result3 }).then
One can really see the parallel with async/await
const awaited1 = await request1();
const awaited2 = await request2({ awaited1 });
const awaited3 = await request3({ awaited1, awaited2 });
const awaited4 = await request4({ awaited1, awaited2, awaited3 });
And we can define a function wait
to make it almost identical
const wait = promise => callback => promise.then(callback)
const result1 <=< wait(request1())
const result2 <=< wait(request2({ result1 }))
const result3 <=< wait(request3({ result1, result2 }))
const result4 <=< wait(request4({ result1, result2, result3 }))
Of course, the poer of the operator is that we can then use this approach to anything that deals with callbacks.
Bikeshedding
Ideally, I'd pick <=
, as it's a nice parallel with your normal arrow function. However, there's obvious conflicts with the lte
comparison operator, and there's similar issues with <-
=<
is another option but <=<
works better with FiraCode unlike =<
Alternatively, we could introduce a new keyword, or overload yield
and allow it outside of generator functions.
This would look like:
function fn(table) {
const row = <keyword> table.map
const col = <keyword> row.map
console.log(col)
}
I hacked together a quick and dirty babel plugin that shows how this desugaring with yield
would work.
You have to use it in generator functions and it completely interferes with normal generator behavior, but it's a good demo of the mechanism, I think
Here's a link to ASTexplorer with that plugin