ES6 Promises
ES6 Promises are finite state machines and thus require complex implementations. Beyond that the Promise/A+ spec comes with a lot of rough edges:
- overloaded
then
(map/chain) - recursive flattening / then-able assimilation
- automatic lifting
- several subscribers (multicast)
- eager evaluation
Multicast distribution and eager evaluation are among other things the reasons why ES6 promises cannot be cancelled. Additionally, we can't add our own layers of then-ables with specific features, because they are immediately assimilated by recursive flattening.
I am pretty sure there were a lot of good reasons for any of these design decisions. However, now we have an invariable core language feature rather than specific, competing DSLs for asynchronous control flows in userland. Surely, interop is important, but so is the ability to evolve async control flow features without having to take backward compatibility of the whole language into consideration.
Continuation Passing Style
Continuation passing style abstracts from asynchronous control flows, since it gets rid of the return
statement. To regain composability we merely need a functor in the context of continuations:
const compk = (f, g) => x => k => f(x) (x => g(x) (k));
const inck = x => k => setTimeout(k, 0, x + 1);
const log = prefix => x => console.log(prefix, x);
compk(inck, inck) (0) (log("async composition:")); // 2
Of course, we want to compose more than two functions. Instead of manually writing compk3 = (f, g, h) => x => k => f(x) (x => g(x) (y => h(y) (k)))
etc., a programmatic solution is desired:
const compkn = (...fs) => k =>
fs.reduceRight((chain, f) => x => f(x) (chain), k);
const inck = x => (res, rej) => setTimeout(res, 0, x + 1);
const log = prefix => x => console.log(prefix, x);
compkn(inck, inck, inck) (log("async composing n functions:")) (0); // 3
This approach completely lacks exception handling. Let's naively adapt the common callback pattern:
const compk = (f, g) => x => (res, rej) =>
f(x) (x => g(x) (res), x => rej(x));
const compkn = (...fs) => (res, rej) =>
fs.reduceRight((chain, f) => x => f(x) (chain, x => rej(x)), res);
const inc = x => x + 1;
const lift = f => x => k => k(f(x));
const inck = x => (res, rej) => setTimeout(res, 0, x + 1);
const decUIntk = x => (res, rej) =>
setTimeout(x => x < 0 ? rej("out of range " + x) : res(x), 0, x - 1);
const log = prefix => x => console.log(prefix, x);
compk(decUIntk, inck) (0)
(log("resolved with:"), log("rejected with:")); // rejected
compkn(inck, decUIntk, inck)
(log("resolved with:"), log("rejected with:")) (0); // resolved
This is just a sketch - a lot of effort would have to be invested to achieve a proper solution. But it is a proof of concept I guess. compk
/compkn
are extremely simple, because they don't have to fight state.
So what are the advantages of complex ES6 promises over continuation passing style and corresponding DSLs like the continuation functor/monad?