The reason why promises resolve asynchronously is so that they don't blow up the stack. Consider the following stack safe code which uses promises.
console.time("promises");
let promise = Promise.resolve(0);
for (let i = 0; i < 1e7; i++) promise = promise.then(x => x + 1);
promise.then(x => {
console.log(x);
console.timeEnd("promises");
});
As you can see, it doesn't blow up the stack even though it's creating 10 million intermediate promise objects. However, because it's processing each callback on the next tick, it takes approximately 5 seconds, on my laptop, to compute the result. Your mileage may vary.
Can you have stack safety without compromising on performance?
Yes, you can but not with promises. Promises can't be resolved synchronously, period. Hence, we need some other data structure. Following is an implementation of one such data structure.
// type Unit = IO ()
// data Future a where
// Future :: ((a -> Unit) -> Unit) -> Future a
// Future.pure :: a -> Future a
// Future.map :: (a -> b) -> Future a -> Future b
// Future.apply :: Future (a -> b) -> Future a -> Future b
// Future.bind :: Future a -> (a -> Future b) -> Future b
const Future = f => ({ constructor: Future, f });
Future.pure = x => ({ constructor: Future.pure, x });
Future.map = (f, x) => ({ constructor: Future.map, f, x });
Future.apply = (f, x) => ({ constructor: Future.apply, f, x });
Future.bind = (x, f) => ({ constructor: Future.bind, x, f });
// data Callback a where
// Callback :: (a -> Unit) -> Callback a
// Callback.map :: (a -> b) -> Callback b -> Callback a
// Callback.apply :: Future a -> Callback b -> Callback (a -> b)
// Callback.bind :: (a -> Future b) -> Callback b -> Callback a
const Callback = k => ({ constructor: Callback, k });
Callback.map = (f, k) => ({ constructor: Callback.map, f, k });
Callback.apply = (x, k) => ({ constructor: Callback.apply, x, k });
Callback.bind = (f, k) => ({ constructor: Callback.bind, f, k });
// data Application where
// InFuture :: Future a -> Callback a -> Application
// Apply :: Callback a -> a -> Application
const InFuture = (f, k) => ({ constructor: InFuture, f, k });
const Apply = (k, x) => ({ constructor: Apply, k, x });
// runApplication :: Application -> Unit
const runApplication = _application => {
let application = _application;
while (true) {
switch (application.constructor) {
case InFuture: {
const {f: future, k} = application;
switch (future.constructor) {
case Future: {
application = null;
const {f} = future;
let async = false, done = false;
f(x => {
if (done) return; else done = true;
if (async) runApplication(Apply(k, x));
else application = Apply(k, x);
});
async = true;
if (application) continue; else return;
}
case Future.pure: {
const {x} = future;
application = Apply(k, x);
continue;
}
case Future.map: {
const {f, x} = future;
application = InFuture(x, Callback.map(f, k));
continue;
}
case Future.apply: {
const {f, x} = future;
application = InFuture(f, Callback.apply(x, k));
continue;
}
case Future.bind: {
const {x, f} = future;
application = InFuture(x, Callback.bind(f, k));
continue;
}
}
}
case Apply: {
const {k: callback, x} = application;
switch (callback.constructor) {
case Callback: {
const {k} = callback;
return k(x);
}
case Callback.map: {
const {f, k} = callback;
application = Apply(k, f(x));
continue;
}
case Callback.apply: {
const {x, k} = callback, {x: f} = application;
application = InFuture(x, Callback.map(f, k));
continue;
}
case Callback.bind: {
const {f, k} = callback;
application = InFuture(f(x), k);
continue;
}
}
}
}
}
};
// inFuture :: Future a -> (a -> Unit) -> Unit
const inFuture = (f, k) => runApplication(InFuture(f, Callback(k)));
// Example:
console.time("futures");
let future = Future.pure(0);
for (let i = 0; i < 1e7; i++) future = Future.map(x => x + 1, future);
inFuture(future, x => {
console.log(x);
console.timeEnd("futures");
});
As you can see, the performance is a little better than using promises. It takes approximately 4 seconds on my laptop. Your mileage may vary. However, the bigger advantage is that each callback is called synchronously.
Explaining how this code works is out of the scope of this question. I tried to write the code as cleanly as I could. Reading it should provide some insight.
As for how I thought about writing such code, I started with the following program and then performed a bunch of compiler optimizations by hand. The optimizations that I performed were defunctionalization and tail call optimization via trampolining.
const Future = inFuture => ({ inFuture });
Future.pure = x => Future(k => k(x));
Future.map = (f, x) => Future(k => x.inFuture(x => k(f(x))));
Future.apply = (f, x) => Future(k => f.inFuture(f => x.inFuture(x => k(f(x)))));
Future.bind = (x, f) => Future(k => x.inFuture(x => f(x).inFuture(k)));
Finally, I'd encourage you to check out the Fluture library. It does something similar, has utility functions to convert to and from promises, allows you to cancel futures, and supports both sequential and parallel futures.