Skip to content

Comparison to Promises

Aldwin Vlasblom edited this page May 14, 2020 · 20 revisions

As mentioned in the readme, Futures are conceptually very similar to Promises. They can be thought of as the "algebraic" counterpart to Promises. This wiki page aims to document the differences between the two control structures.

A brief history

When the Promise/A+ spec was drafted, an issue was raised to make Promises conform to the "monadic interface": A collection of container-related behaviors defined by the mathematical field of category theory. The people behind the Promise specification wanted nothing of it:

Yeah this is really not happening. It totally ignores reality in favor of typed-language fantasy land -- comment

This is how the Fantasy Land algebraic specification was born and incidentally, where it got its name. Fantasy Land defines the interface, function signatures, and laws that a structure must conform to in order to be monadic.

Fluture provides a structure with equal capabilities to Promises but with a monadic API. It can therefore be thought of as Promise's more algebraic cousin.

Practical differences

new Promise ((res) => { setTimeout (res, 200, 'Hello') })
.then (x => `${x} world!`)
.then (x => Promise.fromNode (done => fs.writeFile ('hello.txt', x, done)))
.then (console.log, console.error);
Future ((rej, res) => { setTimeout (res, 200, 'Hello') })
|> map (x => `${x} world!`)
|> chain (x => Future.node (done => fs.writeFile ('hello.txt', x, done)))
|> fork (console.error) (console.log);

On the surface Futures are just like Promises, but with the different behaviors of the .then method extracted into three distinct functions, each with a single responsibility:

  • map: Transforms the success value inside the Future. This happens in Promise if you return anything that doesn't look like a Promise from f in .then(f).
  • chain: Absorb the state of another Future into the main Future (flat map). This happens in Promise if you return something that looks like a Promise from f in .then(f).
  • fork: Evaluates the computation using the given continuations. Promise are automatically evaluated (more on that below).

This makes code more logical, because the abstraction isn't making decisions for you. It also clarifies developer intent and allows for more descriptive error messages. Plus any utility written for Fantasy Land compatible types (like Ramda or Sanctuary) will also work on Futures.


Eagerness vs laziness

Promises are eager by design. When a new Promise is constructed its computation is immediately evaluated. When a Future is constructed, its computation is only evaluated once the continuations are provided. This has several implications for Promises:

  • The computation runs immediately, before callbacks are available, so the resulting value has to be kept somewhere, making Promises inherently stateful.
  • Any side-effects will run, even if continuations are never provided. This means Promises cannot be used to control side-effects.
  • Errors might occur as a result of the evaluation, but Promises have no way to know whether they will be handled.

Statefulness (caching)

As mentioned above, Promises have to be stateful. This means that once it resolves to value or an error, it will stay in this new state. In other words; reevaluation becomes impossible. Every time you call .then, the value is loaded from a cache.

Futures on the other hand are completely stateless by default. Every time you .fork them, they reevaluate their computation. In most cases, you won't notice this difference; Promises and Futures are generally only forked once. If you want caching, it's quite easy to create a utility that does this for you. One such utility is cache.

Error handling

Promises are very involved in handling your errors. Errors thrown within continuations given to .then are automatically caught and flow into the rejection branch. Ironically, Promises then allow you not to handle these errors by making handler optional in .then(continuation, handler).

Fluture has a different approach: Thrown errors are never caught unless explicitly requested by the developer through functions like attempt. The philosophy here is that if an error is thrown rather than passed into the rejection branch; it must be a developer mistake (aka. bug), and the program should crash and restart. A benefit of this behaviour is that any exceptions which end up in the rejection branch of a Future are solely expected failures, meaning your program is less likely to enter an invalid state. More on this philosophy is very neatly described by @rpominovs document on exceptions.

Additionally, when a Future rejects the developer would have been forced to deal with it: handler is not optional in fork (handler) (continuation), and as we've seen; the Future doesn't even run if fork wasn't called in the first place.