Cancelation without Breaking a Promise
Reflecting on what was so tricky about cancelable Promises, embracing functional purity as asolution
Obviously, this makes activating and responding to truly asynchronous actions a bit tricky. The first and most natural pattern that can help handle this in user-level code is callbacks: you pass some function another function that you want to be called with a result once some other interface declares that a result is available. There’s nothing even inherently asynchronous about this pattern: it’s just basic higher-order functions at play:
All synchronous, but with the same layer of abstraction in place that asynchronous code would require
//setTimeout:: Function -> Int -> a setTimeout(x=>console.log(x), 700, 5);
In this case, we just specify a callback function, an amount of time to wait before calling it, in milliseconds, and, optionally, the value to call the callback with. Given all this, the language will wait the appropriate amount of time then then call the function with the value.
That’s it. Basic. There’s no polyfill for non-blocking setTimeout that I know of: either the language can do that (that is: wait, without blocking the execution of any subsequent lines of code) or it can’t.
But one structural limitation with setTimeout is the “dead-end” nature of its API: the side-effecting callback is not particularly extensible. That is, if you want to extend and chain further computations onto the result of the callback, you can’t do so after defining the core callback operation itself. You’d have to just get everything you want to have happen ready and all packaged up inside the callback in the first place, before handing it off to setTimeout to be executed. Now, the callback could be made to trigger other functions that are already defined in an outer scope… or even to schedule them to run later using another setTimeout . But the fact remains that the actual return value returned from the callback doesn’t really GO anywhere:setTimeout(x=>x, 700, 5);
Above, our callback function returns a 5 as its result, but it’s basically pointless: the callback is defined synchronously (in the present) but it runs asynchronously (in the future). And so there’s no place for the “5” to go: nothing else is or can be defined for it to feed into. Nothing “else” exists in its future timeframe to listen to it.
Can we introduce this concept of “future listening”? Certainly! And that’s how we get Promises. Promises are like a representation of a value that will exist in the future… and thus provide you with an intelligible way to code against those missing values in the present. They do this by sort of “boxing up” a future event: containing an eventual explosion.
Thus, when you construct a Promise, you define a function that internally executes something like a setTimeout operation but then also specifies how to capture that result by calling one of two specialized functional handlers: resolve or reject. Like this:
Hopefully I don’t need to go into the api of Promises too much. Instead, let’s call out a few key things that will quickly become relevant to our discussion of cancelation:The constructor function (the one that’s passed the special resolve/reject arguments that control the Promise’s internal state) didn’t actually return anything (so, implicitly, it returns undefined ). This is normal for promises just like it was for setTimeout . Even if it does return something, it doesn’t matter . new Promise returns, well, a Promise: not whatever arbitrary result the constructor returned. As we said, the constructor function is executed immediately , as soon as the operation is defined. If you immediately chain a .then() method onto a promise, the result is a new, derivative Promise whose eventual state depends on the outcome of the first. This second, derivative Promise has no (and really, should not have any) hook back “into,” or control over, the original promise we created using the constructor! It simply derives its own “resolve” execution from the outcome of the former one. This channel of communication has room for only one thing, and in one direction: the value passed into the original resolve callback function, which is then farmed out to whatever function is attached to the promise using .then() .
I lied a tiny bit in #3 of course: there IS another channel of communication: reject / .catch() But reject doesn’t really change the key details of the story that much: if an operation rejects, that just feeds a value into the reject handler of the next .catch() operation. Same as resolve did. And, same as resolve, it’s a function called with just a single value. There’s not a lot of room for extra signals in that pipeline! And that’s actually a very good thing, because we’re already dealing with a complex construct we’re trying to make simple & concrete.Forestalling thefuture
But now we get to the meat of the matter: cancelation. What if we decide at some point that we don’t care about the original operation while we’re still in the middle of it? We’d expect two critical things to happen:whatever operation was asynchronously generating a result should stop, freeing up any resources it was consuming none of the side-effects depending on that result (or were, at least, waiting until it arrived) should ever run
This seems deceptively simple, right?And working with just a bare setTimeout , it is actually pretty easy. While it might be an interface for creating asynchronous effects, setTimeout does return something immediately as soon as it’s called: a unique timing id. And if you’ve ever worked with setTimeout , you know that y