Promises icon indicating copy to clipboard operation
Promises copied to clipboard

A proposal for ending the wrapping/unwrapping war.

Open slightlyoff opened this issue 11 years ago • 23 comments

Doing this here because the mailing lists have turned into an unfollowable mess.

First, some cautionary notes: this thread will use the terms "wrapping" and "unwrapping" to refer to APIs that treat Futures as something other than direct values. Posts here that use different terminology will be edited to conform.

Next, please be civil and cordial. Those who don't agree with you aren't bad and they might not even be wrong. Goodwill will get us where we want to go.

OK, down to it:

It seems to me that the contended bit of the design is .then() (and by extension, .catch()). Both pro and anti-unwrapping camps can have their own way at resolution time using .resolve() and .accept() respectively. And both have valid points:

  • for those who live in a very Futures-driven world, it's hugely common to want to treat values and Futures/Promises without distinction.
  • for those making the transition, or who have different contracts that they want to service with regards to some non-Futures-loving service, having directness can be a virtue.

On top of these points, there are preferences for styles of use. These can't ever be truly enforced with rigor because it's trivial to write wrapping/unwrapping versions of direct functions and vice versa. So there are cultural differences that we must admit with regards to preferred styles of use.

With the caveat that all involved are willing to live-and-let-live, I observe that it is possible to end the current debate in everyone's favor so long as we add a "direct" version of .then(). That is to say, one which has the semantics of .accept() with one level of unwrapping for a returned thenable and not .resolve() (infinite unwrapping) for values returned from callbacks.

I'll submit .when() as a straw-man name, but I'm not precious about it.

So the question now is: is everyone cc'd here willing to make such live-and-let-live compromise? And if not, why not? And is there some other detail which I've missed?

/cc @domenic @dherman @wycats @annevk @tabatkins @erights

slightlyoff avatar May 17 '13 15:05 slightlyoff

a "direct" version of .then(), e.g. one which has the semantics of .accept() and not .resolve() for values returned from callbacks

I think I know what you mean by this, but just to be sure, could you tell us what the fulfillment value ("acceptance value"?) of x, y, and z are in the following?

var a = new Future({ accept } => accept(5));
var b = new Future({ accept } => accept(a));

var x = a.when(() => 5);
var y = a.when(() => a);
var z = a.when(() => b);

domenic avatar May 17 '13 15:05 domenic

I tried reading this but had a hard time understanding in between the cautionary notes and preferences stuff. The idea is to not recursively join promises on then and resolve. when called via new methods?

I know we're in JavaScript-land but some pseudo type-signatures would really clear this up for me.

puffnfresh avatar May 17 '13 15:05 puffnfresh

Hey @domenic:

x == 5, y == 5, and z == a

At least that's my read of the design that @tabatkins was gunning for. I'm open to arguments that would make y == a and z == b, but I've been told that nobody wants that = )

slightlyoff avatar May 17 '13 16:05 slightlyoff

Ah. I don't think that's very conceptually coherent :-/. If you want a counterpart to accept, it should be y ~ a and z ~ b. I think that this desire for conceptual coherence was the main idea behind @erights's "The Paradox of Partial Parametricity".

domenic avatar May 17 '13 16:05 domenic

My goal here is to remove the strain from .then(). I think there is a missing method. What it does, I'm somewhat less concerned about.

Waiting for @tabatkins and @dherman to weigh in.

slightlyoff avatar May 17 '13 16:05 slightlyoff

Like Dominic, I think that if we were to have a separate .when() function, then it would be most useful/consistent if that function gave

x == 5 y == a z == b

This is the result you'd get if the implementation of .when() called .accept(returnValue) rather than .resolve(returnValue).

But I could be biased because I think that .resolve() and .then() should never recursively unwrap promises. I rather think the question is if we should allow them to become deeply wrapped or not.

If we're going to have both .then() and .when() I would strongly prefer to have clearer names. I.e. where it's more clear what the difference in behavior is between them.

sicking avatar May 17 '13 21:05 sicking

@domenic x is 5, y is 5, and z is Future<5>, or a.

@sicking Oh goodness, no. That would make it a map, which is much less useful. What I've wanted this whole time is a monadic bind function, which does single-level unwrapping. (Once you have bind, you get map for free.)

@slightlyoff If we're going to go this way, I think it would probably be worthwhile to be strict about things, and make .when() strict about the return values of its callbacks. Right now it's loosey-goosey, acting like a bind if it can, and falling back to a map otherwise. Being strict means that x would be a rejected promise containing a TypeError, because the return value of the callback wasn't a promise, then y is 5 and z is a.

That behavior is more predictable, though less generally useful. I might be okay with this - .then() is the really loose, friendly function that accepts direct values or promises, and it fully unwraps so that its returned promise only ever contains direct values. .when() is the stricter and more predictable monadic function.

In that case, let's call the function .chain() instead, like @puffnfresh does in his Fantasy Land spec. It's a good general-purpose name for the monadic binding operation (I never really understood why Haskell calls it "bind" in the first place), which means that other structures can re-use the same name without trouble, and we can do good monadic stuff. It's also further from .then() in name-space, which should help people to distinguish them, and I think it's somewhat clearer in general - you're chaining two promises together, which is why you can't return a non-promise.

tabatkins avatar May 17 '13 22:05 tabatkins

:+1:

https://github.com/puffnfresh/fantasy-land#chain

The chain method would allow us to immediately make use of Fantasy Land derived functions for DOMFutures, which would be really great. The only requirement is that the law associativity is obeyed, which seems to be true.

puffnfresh avatar May 17 '13 22:05 puffnfresh

So, some details you missed: ^_^

(Assuming that the new operation is called chain.)

Your proposal details differentiate between then and chain by the way they treat their callback return values, with then fully unwrapping and chain single-unwrapping. This works fine if you only use then or only use chain, but if you mix them, you'll run into trouble - a then callback can receive a Future, or a chain callback can have its argument unceremoniously collapsed.

Instead, they should differentiate on the argument side - if the Future contains nested Futures, the then callbacks don't get called until they all resolve and you end up with a plain value, while the chain callbacks fire as soon as the outer future resolves. It's possible for a then callback to return a nested Future, but if you then continue to use then you won't ever see it.

tabatkins avatar May 20 '13 05:05 tabatkins

Anyone willing to create (preferably real-world) JS examples of when & how you'd use one over the other? My first reaction is the single unwrap is weird but feel I'm missing something

jakearchibald avatar May 21 '13 11:05 jakearchibald

Still hoping @dherman will weigh in, but a few observations:

  • I'm not opposed to .chain(), particularly not with the strictness stipulations. It's clear that it's dealing in Futures and not values under those conditions, and the name points in that direction.
  • It's not clear that we need .chain() right now and it can be added later. A stand-alone chain(f, cb) method that takes a Future and callback gets you there on the back of the current design. You don't need anything new from Futures to effect this, AFAICT. Am I wrong about that?

Thanks.

slightlyoff avatar May 21 '13 11:05 slightlyoff

@jakearchibald Lots of discussion about this in the various threads. Single-unwrap helps in scenarios where different futures represent fundamentally different types of things. For example, a database retrieval might return a Future, and the thing stored in the database might be a Future. You may want to just wait for the database retrieval to finish and then get to work on the value immediately, rather than having to wait for both of them to finish.

This is especially true when we add LazyFuture or whatever, which doesn't start its operation until someone registers a callback.

I don't like multi-unwrap at all (unless done explicitly), because I think generally it'll just paper over programming errors, but fuck it, if I can get a good chain out of this, I'll accept a recursive-unwrapping then.

@slightlyoff I don't understand. Your starting post in this thread was attempting to resolve the issue by splitting it into two operations, and now you're trying to suggest that we don't need two operations at all? Don't bait-and-switch me, bro.

But no, you can't build chain on top of recursive-unwrapping then without bullshit extra wrappers designed solely to foil auto-unwrapping, because your Futures won't stack. It would be extremely clumsy and dumb, and wouldn't work like a normal Future at all.

tabatkins avatar May 21 '13 17:05 tabatkins

If only there were an alternative method like .then()which exposed the new Future's resolver to it's callbacks. Then you could accept / reject / resolve your intended .then() callbacks in anyway you need. So you could do:

.thenWithResolver( resolveOnce( getSomething ) )  // unwrap once if getSomething(value) returns a promise

.thenWithResolver( accept( getSomething ) ) // always accept, even if only a promise

.thenWithResolver( rejectPromises( getSomething ) ) // Don't give me promises!

.thenWithResolver( rejectPromiseStreams( getSomething ) ) // I'm not trusting your promise a second time!

.thenWithResolver( future_invoke( obj, "getSomething" ) ) // .then( obj.getSomething.bind(obj) )

.thenWithResolver( translatePromises( getSomething ) ) // It's a promise Jimmy, but not as we know it.

It's just an idea off the top of my head really. In general though, the right way to do all these things is to create another new Future inside a .then() callback.

shogun70 avatar May 22 '13 03:05 shogun70

@shogun70 What you're saying doesn't make any sense. Neither have any connection to the question of nested promises.

Returning a Future from a .then() callback is already a useful and accepted pattern - it gets unwrapped and the chained future adopts its state. The question is about returning a Future for a Future.

tabatkins avatar May 22 '13 19:05 tabatkins

@slightlyoff So, the best, most consistent model is this:

Futures can stack. No magic, nothing special, you can just wrap anything in a Future, including more Futures. then waits until all nested futures have resolved before calling its callbacks with the final non-future value. chain just waits for the outermost future, and calls its callbacks with whatever the value of that is, whether it's a plain value or another future. Both are the "simpler" option, depending on who you ask.

(The treatment of callback return values isn't strictly important, but then can accept either futures or plain values, treating the latter as if it had been wrapped in an accepted future. chain only accepts futures. Both have their chained future adopt the returned future's value, doing single-level unwrapping. Of course, if you keep using then, the fact that it only unwraps the return value one level is invisible, since your callback isn't called until all the levels are unwrapped.)

This solves all problems, forever. If you have a nested Future where the two Futures are substantially different kinds of things, such that you only want to wait for the outer future to finish, not both, you can use chain to interact with it. If you don't care about all those details, and just want the final value, you can use then. There are use-cases for both - I've given use-cases for chain, and the use-cases for then are obvious, and all of the "I don't give a crap about your asynchrony, just call me when you're done" variety.

This also happens to satisfy people who like monads, because chain is the monad operation over futures.

It's possible to build then on top of chain without too much difficulty, but you can't do the opposite without a lot of annoying bullshit, like a non-thenable wrapper object designed solely to foil the recursive resolving.

tabatkins avatar May 22 '13 23:05 tabatkins

So just to report on the TC39 meeting's results, we have made the following changes to a new Promise.idl that is now the basis for discussion:

  • .then() as we know it continues
  • .fulfill() is the new .accept() and is now available along-side .resolve()
  • there is no .chain() operator, but it can be added by subclasses

I accept that .chain() explains .then() and not vice-versa, but I'm not sure that the new current state is bad: it neither precludes adding .chain() in the future, nor does it prevent libraries from adding their side-contract version, nor does it prevent anyone from advocating for their preferred style.

slightlyoff avatar May 23 '13 13:05 slightlyoff

Ugh, that's incoherent, though! Setting aside chain for the moment, the consensus you describe means that then is not guaranteed to get a plain value - authors can construct a nested future with fulfill and then call .then() on it, and the callback gets a future, not a value.

This is just silly. If you want Mark/Domenic's then, then either remove fulfill so you can never produce a nested Future (I don't recommend this) or make then do the unwrapping at the read side, not the return side. The difference is unobservable to code that only produces singly-wrapped Futures, but it acts much better when nested Futures show up.

And no, you can't introduce chain via subclasses. Just try it. The only way to do it has already been demonstrated by Domenic, and it produces something that is unusable by things that expect normal promises without special-case ugliness. It also requires ES6 argument destructuring just to be usable; without that, it's beyond the pale in usability. Even with destructuring, it's not all that usable - Domenic uses an {x} in his code, which looks like a standard placeholder argument name, but it's not - x is actually part of the contract, and you have to use it (or use the longer {x:foo} form to name your argument foo).

tabatkins avatar May 23 '13 18:05 tabatkins

It's just functional composition @tabatkins. .then() callbacks don't need to be modified - they can still return a value-or-promise. Or a promise-for-a-promise. To modify the way you handle those things - single unwrap; infinite unwrap; immediate accept; etc - it's just the wrappers that need to change.

So,

.then( getSomething )

can receive exactly the same getSomething callback as

.thenWithResolver( resolve ( getSomething ) )

Assuming thenWithResolver passes its new future's resolver as the first arg (like new Future() init callbacks), resolve is simply:

function resolve( fn ) {
    return function( r, value ) { // this doesn't need try-catch, because ...
        result = fn( value );  // ... if this throws `.thenWithResolver` will reject
        r.resolve( result );
    }
}

In long hand that would be:

function resolve( fn ) {
    return function _resolve( r, value ) {
        result = fn( value );
        if ( isThenable ( result ) ) { // approximately: ( result && typeof result.then === 'function' )
            result.then ( _resolve.bind( null, r ), r.reject );
        }
        else r.accept( result );
    }
}

Continuing on:

.thenWithResolver( accept( getSomething ) ) // always accept, even if only a promise
...
function accept( fn ) { 
    return function( r, value ) { r.accept( fn (value ) ); }
}

chain seems to be a single unwrap:

.thenWithResolver( resolveOnce ( getSomething ) ) //  unwrap once if getSomething(value) returns a promise
...
function resolveOnce( fn ) {
    return function ( r, value ) {
        var result = fn( value );
        if ( isThenable ( result ) {
            // unwrap once and then accept, even if a promise
            result.then( r.accept, r.reject ); // assuming `accept` and `reject` are bound methods
        }
        else r.accept( value );
    }
}

This approach seems quite straight-forward. I can't see any difficulties for someone who knows enough to need non-default behavior.

shogun70 avatar May 24 '13 05:05 shogun70

@shogun70 I've argued this with you in the mailing list, and won't repeat it here. Your proposal is less convenient for common cases, in return for being slightly more convenient for interfacing with legacy callback APIs. It doesn't match any of the major promise libraries.

tabatkins avatar May 24 '13 16:05 tabatkins

You are reacting to a non-essential implication of my proposal which I haven't even mentioned in this discussion.

Lower-level alternatives for then / catch don't require the deprecation of then / catch.

And they provide more flexibility, including the feature you are after.

Why is it up to the API to say that JS devs will only want the default behavior?

You are very aware that some JS devs want non-default behavior, because you are one of those people. Why limit that non-default behavior to only one option, when you can leave that decision to the coder?

shogun70 avatar May 24 '13 22:05 shogun70

Every time I read these javascript discussions on Future, I see an emphasis on the fact that it is a monad. The Future monad brings great benefits. Of course, we'd also need a library that parameterises on all monads to truly exploit this benefit. Good luck with that! However, this is not why I write.

Rather, I wish to stand in place and defend the utility of the forgotten future function. Future is a semi-comonad and I further this claim with the following: while future's monad is ubiquitous and useful in everyday programming, its dual, the semi-comonad, is at least just as much, if not more!

The following table demonstrates the relevant functoriality to this discussion.

  • Future is a covariant functor i.e. has map obeying laws of identity and composition.

    (a -> b) -> Future a -> Future b

  • Future is an applicative functor i.e. has ap obeying laws of identity, composition, homomorphism and interchange.

    Future (a -> b) -> Future a -> Future b

  • Future is a monad i.e. has bind obeying law of associativity and point being the identity for bind.

    (a -> Future b) -> Future a -> Future b

  • Future is a (semi-)comonad i.e. has cobind obeying law of associativity.

    (Future a -> b) -> Future a -> Future b

Please do not forget my friend, cobind.

tonymorris avatar May 24 '13 23:05 tonymorris

Indeed, Future has cobind as well (or as I usually see it named, extend). You can implement that on top of either Future design. ^_^

tabatkins avatar May 25 '13 01:05 tabatkins

The problem with this API is that it is neither intuitive, nor symmetric for the lay man.

accept() and reject() are clear, so are success() and failure(). Once you begin talking about resolve() or fulfill() you went off in the wrong direction.

I suggest renaming:

fulfill() to success() resolve() to next() or chain() reject() to failure() then() to onSuccess() catch() to onFailure()

These might not be the most elegant names, but they are damn intuitive (at least to me).

Please rename the API methods to something more intuitive and symmetric. Thank you!

cowwoc avatar May 31 '13 00:05 cowwoc