Introducing a "Backcall" operator to address nested code

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

1 Like

That seems like a more confusing version of https://github.com/tc39/proposal-pipeline-operator to me.

I don't really see how?

The pipeline operator is about a sequence of function calls, not about callbacks?

This just "unnests" callbacks, the same way async/await is unnesting a sequence of Promise.thens

You still have a nested problem with a pipeline operator, where

(list => (x => x + 1) |> list.map) 
  |> listOfList.flatMap  

I find that really confusing, although probably the more functional solution is probably

listOfList 
  |> A.flatMap(list => list
    |> A.map(x => x +1))

That still doesn't get rid of nesting? I might be misunderstanding your comment tho @ljharb

Looks confusing to me. It took me a while to figure out the <=< is not so much an operator, as it is a variable declaration modifier that's introducing a nested scope. In the end it seems like you're mostly removing indentation and annoying braces, without actually removing nesting.

One of your examples:

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()

could be written today as:

app.use((req, res, next) => {
authenticate(req, authResult => {
if (!authResult.isAuthenticated) return res.send("Not authenticated");
updateSession(req, _ => void next());
});
});

Same amount of indentation. Your proposal gets rid of those pesky closing braces, which is nice, but not enough to offset the loss of clarity compared to the original, IMO.


Bad idea. Would be super confusing if this:

const x <=< f('bar')

worked differently than this:

const g = f('bar')
const x <=< g

If you make <=< rebind arguments like a regular function, it should also rebind this. Both would be confusing, IMO. Better to allow multiple arguments and explicit this, if you want it in the backcall:

const this, x, y, ...rest <=< foo(#)

I fail to understand how it is different. It appears to be exactly the same thing to me?

I'm a little confused at how this is getting desugared:

const fn = table => {
    const row <=< table.map
    const col <=< col.map
   
    return doSomething(col)
}

is being converted to

const fn = table => {
    return table.map(...)
}

Where did that "return" come from that's before table.map(...)? Will a "return" always be auto-inserted whenever this "<=<" syntax is used? What if I wanted to use "<=<" to remove some nesting, but I didn't want to return the result? (e.g. maybe the code was there purely for side-effect purposes).

@lightmare

I suppose operator really isn't the correct term here, just like => isn't an operator either.
Perhaps just refer or think about it as backcall syntax? Maybe a keyword(s) would be preferential, like

const x from array.map

I don't think it's just about the indentation and braces. When async/await was introduced, one could make a similar argument: that it's primarily about reducing .then() chains and making code "look" nicer.
But the core intention goes beyond that. We turn deeply nested callbacks into more linear, sequential and imperative code, which can significantly enhance readability, especially in complex scenarios. We also provide a general mechanism to deal with any callback heavy pattern, not just Promises. This would make code more consistent imo, and could even be less confusing for new developers than the async specific "magic"

Agree that checking for a callExpression was a bad idea. I suppose restricting it to an unary function is also less than ideal but could be worthwhile on its own. The placeholder idea is not my favourite, but given that the pipeline proposal is heading that way, it'd be more consistent
And also good point on this and arguments too!


@bergus

There's definitely similarities. I think the key difference is that "do notation" works on specific data structures which implement a specific interface, the Monad. This interface has a function, typically called bind which is used for desugaring into the CPS style.

const x <=< value

would be desugared as

bind(value, x => ...)

with the different implementations of bind defining the behaviour of what happens. You can think of async/await as a specialization of do notation for Promises, with async introducing the Promise context and await implementing bind for Promises.

With a backcall however, we don't require anything, no implementation for bind or anything. it just acts on callbacks, doesn't need to be tied down to a data structure, or some interface/protocol/pattern. If you want, like I showed in the example, you can emulate do notation by implementing bind, but then that's an explicit call to bind, which I think is a good think


@theScottyJam

Ah! Thanks for pointing that out! I don't think a return should always be auto inserted, I just didn't consider the no return case :/
I suppose one idea would be to allow a return keyword on the RHS of the <=< ?
So const row <=< return table.map
Not sure how I feel about that, it does seem consistent at first glance? WDYT?

With async/await, we always have a promise being returned, and the monadic value is dictated by the callback return value. Following that logic, one could also argue that the final return in doSomething could triggers the return on table.map, but then what about returning within the callback?
I'll try to give it some more thought, thanks for the feedback!

Appreciate everyone sharing their feedback, hopefully something productive can come out of this!

EDIT: minor styling

WDYT

I think this particular style of syntax is running into a number of issues related to expectations about the function being called taking one parameter (or fancy syntax to get around that), or expectations around how the resulting value will get used (or fancy syntax to get around that).

I don't know how far you read into some of the other threads you linked to, but I'm partial to this little idea I threw around over here - i.e. we just put a "..." inside a callback instead of a normal body, to signify that we want the rest of the outer block to be treated as part of the callback. It doesn't look quite as pretty as "<=<", but it also deals with all of these edge cases very neatly and it's, IMO, easier to get a grasp on what it's doing.

Another option would be to request a backwards pipeline operator, that's intended to be used for the purposes of flattening a nested chunk of callbacks - something I once brought up here - which was an interesting discussion.

In short, I think the problem of handling nested callbacks is an interesting one to solve, but I believe the solution for it has to be very simple - the problem just isn't big enough to warrant anything else.

Yes, I'm inclined to agree, but that is the same problem as the pipeline operator. If that gets implemented, it's not such a special syntax/concept anymore. Naturally, any proposal that first implements new syntax should provide a significant advantage, but, at least for an early stage discussion, I don't necessarily see considering new syntax to be that detrimental.

I skimmed through that proposal because it was focused on proper monadic notation, as in, you're denesting via some provided bind behaviour, probably introduced before the curly braces block. I do think that something like the monadic functions, or block patterns mentioned in your other link, will eventually find it's way to JS, but I think there's more issues to tackle there than simply finding a good way to linearize pyramids of doom.
I saw your comment but wrongly dismissed it assuming it pertained only to monads. Using ... confused me, but the other example you provided is pretty inline with what I had in mind here.
To clarify, I prefer the following version you gave:

const response from fetch(url).then((response) => { ... })

And that's essentially what I'm aiming for as well, but that's no different that using a placeholder with <=<. Or for example:

const response = fetch(url).then(unnest)

However, doesn't this still retain the return problem you previously mentioned, or did I miss something?


Some brainstorming:
Having an indicator for what to unnest could have some advantages, my first though was:

const response = app.route("/", (unnest res, req) => doStuff, err => handleIt)

Which allows you to linearise the happy path and perform some checks on the fly. Could be interesting


Regarding the backwards pipeline, that was an interesting read, I never thought of using the normal pipeline to unnest like that, perhaps that was what @ljharb was referring to?
As a side note, I really dont find

map(blah) |> console.log("Mapped: ", %)

better than

console.log("Mapped: ", %) <| map(blah)

What's more readable depends on the situation. The second even has some advantages. Yes, map is evaluated first in both cases, but reading through code I'm often more interested in the "final product" that every little detail along the way. I need to go through each step of the forward pipeline to get to the end result, whilst the other way around I only need to check each step if I'm actually interested in them.

I've used a language with backcalls before, years ago: https://livescript.net/

Memory leaks are unfortunately ridiculously easy to encounter, and it's difficult to compose with different types of asynchrony (lists, promises, observables, etc). Ultimately, I ended up stopping using it for these two reasons - they just don't scale even for many smaller projects.

I'm interested, how do the memory leaks come about?

You're kinda contradicting yourself here. When you say that backcalls act on callbacks, that is a protocol. You expect the right hand side of the <=< operator to be a function (or even, method reference) that accepts a function. Just like async/await expects the await operand to be an object that has a .then() method accepting two callbacks, or like "do notation" expects the operand to be an object that has a flatMap (or better [Symbol.bind]) method accepting a callback. (Or in other languages the bind function is inferred by the type system from the static type of the operand, but that is not an option in JavaScript)

I can see the appeal of your approach though. It allows choosing the method to call, which makes it easy to mix different data structures and really any method accepting a callback - like the functor method map. However, if you are actually working with a monadic data structure - and that is the only case where really deeply nested callbacks appear - it would be quite annoying having to always specify the bind method.

So I see little difference between

monadic function increment3dArray(arr3) {
   const arr2 <=< arr3
   const arr1 <=< arr2
   return arr1.map(el => el + 1)
   // or
   const el <=< arr1
   return [el + 1]
}

and

backcall function increment3dArray(arr3) {
   const arr2 <=< arr3.flatMap
   const arr1 <=< arr2.flatMap
   const el <=< arr1.map
   return el + 1
}

Btw yes I would insist that a function using this syntax would always need to return the result of the function/method call which the callback was passed. I cannot see a use case where one would not want that. However, I can see cases where one does want to do something with the result in the remainder of the code, without leaving the function. Basically what you did in your "comprehension" examples. And that is where the similarity with "do notation" is undeniable:

const arr3 = [[[0], [1]], [[], [2, 3]]]
const result = do {
   const arr2 <=< arr3.flatMap
   const arr1 <=< arr2.flatMap
   const el <=< arr1.map
   return el + 1
}
console.log(result)

Btw, how would this work with control structures? I can use those with async/await and with yield in generators, which I why those are so useful. But what would happen if I were to write

if (condition) {
   const x <=< arr.flatMap
   return [x, x+1, x+2]
} else {
   const y <=< arr.map
   return y+1
}
console.log("after either?")
try {
   const x <=< arr.map
   throw new Error(x)
} catch(err) {
   console.log(err)
}
for (const arr2 of arr3) {
   const arr <=< arr2
   return arr.slice(0, 2)
}
console.log("after?")

I think all of these would need to become syntax errors.
This also means that <=< cannot be a normal operator, just like yield and await it could only be used in specific lexical contexts that are introduced with special syntax (like do) on their own.

Disregarding whether or not the result should be returned, which I think there's valid concerns there either way, why not desugar your example of:

if (condition) {
   const x <=< arr.flatMap
   return [x, x+1, x+2]
} else {
   const y <=< arr.map
   return y+1
}
console.log("after either?")

to

if (condition) {
   arr.flatMap(x => { return [x, x+1, x+2]})
} else {
   arr.map(y => { return y+1})
}
console.log("after either?")

And same for the try/catch and for loop, simply take the remaining statements in the current block and wrap them in the callback. Do you envision this being tricky in these scenarios you mentioned?

It's subtle, as all local memory leaks are.

Suppose the following:

foo(a => {
    bar(b => {
        baz(c => {
            use(a)
            qux(d => {
                use(b, c, d)
            })
        })
    })
})

You have the following dependencies:

  • baz needs bar to reference a from foo
  • qux needs baz to reference b from bar
  • Reference chain is qux -> baz -> bar

Once baz is called, a is no longer needed (and thus baz), but due to scope inheritance, a will still remain referenced by b's closure until b is no longer needed.

If qux is the result of an async loop, the GC won't be able to collect it (and the rest) until that loop ends. This blocks collection of b's closure and thus the a it inherits from bar's closure.

If this were a backcall, it'd look too much like an await (which doesn't leak like this):

// Leaks until `qux` body is no longer referenced
a <=< foo
b <=< bar
c <=< baz
use(a)
d <=< qux
use(b, c, d)

// Doesn't leak
a = await foo
b = await bar
c = await baz
use(a) // actually collected this time, could be re-allocated for `d`
d = await qux
use(b, c, d)

Backcalls in loops are even more dangerous for creating such (theoretically transient, but in practice long-held) leaky conditions.