Re: Promises: Auto-assimilating thenables returned by .then() callbacks: yay/nay?

On Fri, May 3, 2013 at 4:17 PM, Jonas Sicking <jonas@sicking.cc> wrote:
> On May 3, 2013 8:29 AM, "Tab Atkins Jr." <jackalmage@gmail.com> wrote:
>> What I've been *trying* to do this entire time is find out *why*
>> native nested promises are harmful.  Every. Single. Person. who's
>> tried to tell me that nested promises are harmful so far, has ended up
>> realizing that they were talking about assimilation, or non-JS
>> promises with weird language semantics, or programmer bugs that would
>> benefit from causing an error, rather than having the
>> excessive-wrapping bug ironed over.
>
> There are several problems with nested promises.
>
> First off they force people to answer the types of questions that
> arise in this thread: How do I get to the value inside the doubly
> nested promise? Which functions recursively assimilate and which
> don't? How do I ensure that my nested promise doesn't get accidentally
> unwrapped? Does that matter?
>
> I.e. permitting nested promises creates a more complex model and with
> that you always get more confusion and more questions.

This complexity doesn't arise automatically or accidentally.  You only
get it if your data model is complex, in which case having Futures map
to that complexity better is a good thing.

You brought up an example of such, where you have a retrieval-promise
(from a db) for a compilation-promise (of an uncompiled function).
These are meaningfully distinct sorts of promises, and while it *may*
be useful to wait for both of them to complete before operating on the
inner value (the compiled function), it's also reasonable to operate
on the levels separately, waiting for the retrieval-promise to finish
and then operating on the compilation-promise directly so you can pass
it around.

I keep asking for *examples* of complexity arising from nested
promises, precisely to show that this complexity is never accidental
(or if it is, it's a bug in your program that should be fixed rather
than papered over, like returning a [[x]] rather than an [x]).

> Second, and related, is the fact that trying to pass around a
> promise-of-a-promise is very hard to make predictably work. There are
> several functions in the current Future API which will either take a
> Future or a value, and if it's a Future will unwrap it. If it weren't
> for nested promises it would be safe to go through arbitrary number of
> such APIs.

I'm not sure what APIs you're referring to here.  There is literally
only one function which takes a Future or a value, and unwraps the
Future: Future.resolve().  That's what it's *intended* for.

Everything else either takes a value and acts on it generically
(regardless of whether it's a Future or not), or takes only Futures.

> It's likewise also possible to always call
> Future.resolve(value) in order to create a promise representing a
> value without having to worry about if that value is a promise or not.

Future.accept() is the generic lift operator, putting a value into a
promise regardless of what it is.  Future.resolve() is for conditional
lifting, when you want to raise things into a promise *if they aren't
already one*, so you can operate on both types of values with a single
code path.  (This kind of thing often shows up for other container
types as well, such as a conditional array wrapper, for making it
convenient to deal with code that can accept either a value or an
array of values.)

> With nested promises you can always call Future.accept(myfuture) to
> reliably create a nested promise. However once you pass that future
> through some other code, you don't know how many times it going to
> pass through APIs that may or may not unwrap layers of promises. For
> example, you don't know how many times that value might pass as a
> return value from a .then() callback, which would cause it to get
> unwrapped.

This is only an issue if you're writing .then() callbacks that don't
use the value they get passed *at all*.  If they did, they'd probably
throw an error pretty quickly, as they'd be given a Future rather than
whatever value type they expect to get.  Most .then() code will
operate on the value it receives, so this is not a concern to me.

If you *do* have some generic Future-processing code that doesn't
actually touch the passed value at all, it's trivial to get it right -
just call Future.accept() on it before returning it.  Code that
doesn't do this is broken code, and I suspect it'll be mostly library
code implementing Future combinators (like Future.all()).

> Third, there's the question of what a nested promise actually means.
> Normally a promise represents "a value after some time". But what does
> a nested promise mean? "A value after a longer time" obviously isn't
> correct since there are no time constraints associated with a promise.
> "A promise after some time" also isn't really meaningful given that
> that simply means "A value after some time after some time".

Your example, which I discuss up above, shows what it really means.
It's not "a value after some time", it's "a value after something else
happens, after some time".  That "something else happens" can be
different for different promises, and this can be significant for your
application.

(Pretty much the *only* case where it's actually "after some time" is
a delay operation, where the time itself is significant.)

> This lack of a real mental meaning is what I think is the source of
> the problems above. I.e. the fact that there is no good mental model
> of what a nested promise actually means makes it hard to reason about
> them and tell what is correct behavior and what isn't.

The mental model is simple and sensical, as I show above.

> This actually ties back to what the *real* question *should* be:
> What's the use case?
>
> I.e. why should we support nested promises? It's a feature and we
> don't add features without use cases. A big reason for this is that we
> can't verify that a given solution works well unless we can test it
> against use cases. So is it a problem that nested promises might get
> arbitrarily unwrapped? Is it a problem that they don't get recursively
> unwrapped when Future.resolve is used? Hard to say without knowing
> what the use cases are.

You've presented one use-case, albeit one whose particular details
require several future steps.  But the precise details of your
presented use-case aren't that important; abstract just a tiny bit to
cover an arbitrary database rather than IndexedDB specifically.

It's not difficult to imagine other similar cases, where you have two
unrelated types of asynchrony that you don't necessarily want to smash
together.

Finally, the monad abstraction has proven very useful in other
languages.  Most of those languages happen to share some features that
JS doesn't have, generally surrounding type systems, but those
features are only marginally relevant to the usefulness of the monad
abstraction.  It's not crazy to think that JS could benefit from this
abstraction as well, and possibly even sprout dedicated syntax to
handle them later - do-expressions are to the monad abstraction as
list comprehensions are to the iterable abstraction.

~TJ

Received on Monday, 6 May 2013 17:43:56 UTC