Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ForbesLindesay/5392701 to your computer and use it in GitHub Desktop.
Save ForbesLindesay/5392701 to your computer and use it in GitHub Desktop.

Arguments in favor of Promises for Promises

The argument in favor of Promises for Promises is structured as follows:

  1. Examples
  2. Lazy Promises
  3. Remote Promises
  4. Partial Results
  5. Error Handling
  6. Testing
  7. Abstract Theory
  8. The Identity Law
  9. Parametricity

Examples

Lazy Promises

It has occasionally proved useful to have promises which are lazy. That is to say they don't actually do the work until someone asks for the value via .then. These are useful in two scenarios:

  1. Providing a fluent API:

e.g. superagent (superagent's actual API is not promise based):

request.post('/api/pet')
       .send({ name: 'Manny', species: 'cat' })
       .set('Accept', 'application/json')
       .then(function(res){
         //Use result
       }, function (err) {
         //Handle error
       });

In this instance, you could return a partially configured request from an asynchronous method, and the caller could finish configuring it and then resolve the request. They can't do that if it's always already resolved.

  1. When you don't know if a value will actually ever be used and it's expensive to compute.

If promises are always flattened, you lose the ability to maintain this lazyness across multiple chained .then calls. You essentially lose some control over when the promises are evaluated.

Remote Promises

Remote promises are a similar problem to lazy promises. The idea here is that you have a "promise" for a remote object, such as a database. You can call methods on it (that return promises) but you can't get the object itself. These remote promises may resolve with themselves when you call then on them. They could equally well just not have a .then method though.

Partial Results

A nice neat example is where a function returns something for which partial results may be useful, and may be available much faster than the whole results. Lets assume for the example that promise can create a promise for a promise and promise.mixin(obj, fn) mixes the keys of obj into the promise.

This code is a get(url) funciton that makes an http request and returns a promise for a promise:

var request = require('hyperquest');
var concat = require('concat-stream');

function get(url) {
  return promise(function (fulfill, reject) {
    var responseStream = request(url, function (err, response) {
      if (err) return reject(err);
      fulfill(obj.mixin(response, function (fulfill, reject) {
        responseStream
          .pipe(concat(function (err, body) {
            if (err) return reject(err);
            fulfill(body);
          }))
      });
    });
  })
}

Example usage:

get('http://example.com/foo.json')
  .then(function (response) {
    if (response.statusCode != 200) {
      throw new Error('The server responded with status code ' + response.statusCode);
    }
    return response;
  })
  .then(function (body) {
    return JSON.parse(body);
  });

Error Handling

If you have a promise for a promise, and you know which part of a multi-stage operation each promise represents, you may be able to do better error handling because you can know more precisely where the error was thrown. It could be argued that this is better served by branding the errors (e.g. node.js ex.code === 'ENOENT').

Testing

I'm including testing here because it's often repeated as an example of why you would swap one monad for another. This doesn't necessitate nested promises, but I want to explain why actually it's not useful at all.

The idea is that in your unit tests you mock out functions that normally return a promise by returning an identity monad. This identity monad is then deterministic and synchronous. There are two reasons why this is misguided:

  1. The added determinism is a myth, most promise libraries are actually very close to determenistic with already resolved promises, and it's more useful to have tests that occasionally exhibit the pathalogical error cases, but be hard to reproduce, than have your tests never generate those error cases.
  2. Promises are always asynchronous, so it's safe to code with that assumption. It's dangerous to code with the assumption that they're always synchronous. If your tests are synchronous but your runtime is asynchronous you can assume neither and may accidentally assume that they are synchronous.

To conclude, it's never a good idea to swap a promise for an identity monad in unit test, just use a fulfilled promise.

Abstract Theory

The Identity Law

Consider a method Promise.of(a) that returns a promise for a. It might be implemented as:

Promise.of = function (a) {
  return new Promise(resolve => resolve.fulfill(a));
};

It is intuitively desirable for the left identity law to hold, it states that the following two things are equivallent:

Promise.of(a).then(f)
f(a)

providing f returns a promise and is deterministic.

This will always be true if Promise.of is allowed to return a promise for a promise, but not if it must recursively flatten its argument.

Parametricity

Parametricity is an idea from typed worlds. The concept is that you build a type out of other types. A good example is a list in C# or Java. Lists in those languages are generics, you can build a list of any type of object and they just take that object as a parameter. You aren't allowed to call any methods on the inner objects, becuase you don't know anything about their type.

Applying that same concept to an untyped world: if you inspect the contents of a promise in any way, and change your behavior based on those contents, you're no longer fully parameterised. This would mean that promises wouldn't be as generic as they could be, and when you deal with them, it's not as easy to reason about their behavior.

@junosuarez
Copy link

If promises are always flattened, it is impossible to maintain this lazyness. You essentially lose control over when the promises are evaluated.

I don't understand this argument.

Outside of a hypothetical specified .done() or some other mechanism not currently in Promises/A+, the only knowledge a lazy promise provider would have to force computation would be attaching an onfulfilled handler, that is, on invocation of .then()

Another example of the "partial return value" from a production application I work on: a function to create a new database object accepts some parameters. first it creates the object asynchronously from locally available values it then initiates the DB request to persist the object, which returns a promise. The resolved value for the first function is both the locally created object and a promise for remote persistence. It is useful to regard these values as separate for two reasons: one value will be available before the other, and one may succeed while the other fails.

@myrne
Copy link

myrne commented Apr 16, 2013

"2) When you don't know if a value will actually ever be used and it's expensive to compute."
This is a bit like hyperlinks on the web (or in specifically designed HATEOAS api's). A web page can link to a multitude of other pages, even really big resources, it's left to the user (or other agent) to decide which one to follow. On the web lazy resources are the norm, not the exception. I think the only thing I'd consider non-lazy resources are images (and other content?) embedded via data: uri's.

Actually, example 1, the chaining can be considered a bit like following links too. Or rather, it's like the agent is submitting forms (i.e. it calls a resource with some parameters). At each step, the agent is given the opportunity to get the result of the query resource it has been building (in "cooperation" with the server - which would translate to the superagent lib). That's the then here.

@medikoo
Copy link

medikoo commented Apr 16, 2013

@ForbesLindesay

Lazy Promises.
I think you mixed totally some concepts and forgot what promise really is. Let's put some things clear:
Promise object is a result of initialized async operation and it doesn't exists before (I think that's logical).
You are after convienent way to trigger async operation. So this use case totally don't fit role of then, we're not even having a promise yet.

It's like you'd like to have lazy arrays, where array is initialized only when map on it's object is called. It doesn't make much sense.

In your example then is:

Request.prototype.then = function (onSuccess, onFailure) {
  return this.sendRequest().then(onSuccess, onFailure);
};

It's not a promise.then

Technically of course you can create promise object before operation is initialized (internally), but it should never be created if it's still undecided whether we gonna proceed with the operation, same way as there never should be promises that are not meant to resolve.

Parital Results.
Firstly, such uses cases are solved just by rejecting the promise when status code is not 2xx. You don't need or event want multiple level promise resolution for that.

Secondly, if you want fine grain control over request, you just don't use get, instead you configure request as you did internally in get. Have you noticed that your usage of get is as long as get function itself?
Purpose of functions is to hide some complex logic and provide simple A -> B solution without exposing internals, your use case is against that.

@Twisol
Copy link

Twisol commented Apr 16, 2013

Per #101, please restrict the comments here to improving the argument For promises-for-promises. There's already some good suggestions on the Against gist.

@myrne
Copy link

myrne commented Apr 16, 2013

At first, I struggled to see why Promise/A+ stands in the way for lazy promises. The crux is in this sentence:
"If promises are always flattened, it is impossible to maintain this lazyness."
Maybe, for the uninitated, a code example could be added how this flattening process works in practice.
Also, I wonder what the consequences would be if the flattening would not take place. Would it mean that, in order to be sure you possess a promise that is going to resolve into an actual value (not a promise of a value), you'd need to call flatPromise = flatten(promise) yourself? Maybe "flat promise" is a nice term to distinguish promises which might resolve into anything (including other promises) to promises which are going to resolve into values?

Note that I'm quite new to promises so please excuse me if I'm asking for obvious stuff.

@myrne
Copy link

myrne commented Apr 16, 2013

It's like you'd like to have lazy arrays, where array is initialized only when map on it's object is called.

@medikoo I'm actually building this under the umbrella name "promised builtins". For me it makes sense. It's an alluring prospect. In some cases, it would remove the need for me to call "then" - ever. I could just do:

logArrayItems = function(array) {
  array.forEach(function(item){console.log(item)}
}

What's so interesting to me is that array could be a true Array or a PromisedArray. As long as I don't use special language features like [index] on it then it simply doesn't matter in the code that consumes the array.
See https://github.com/meryn/promised-builtins for a rough sketch.

@myrne
Copy link

myrne commented Apr 16, 2013

Actually, in my earliest conception, I only have thought about the case where the process of finding the value for the promise had already been kicked of (it was inspired by regular async programming), but making the objects potentially lazy is even more elegant.

@medikoo
Copy link

medikoo commented Apr 16, 2013

Also, I wonder what the consequences would be if the flattening would not take place.

It would be cumbersome to lock complex async flow in one generic function, also see this example:

var lintDirectory = function (path) {
  return readdir(path).map(function (fileName) {
     return readFile(fileName).then(lint);
  });
};

// No lazy promises (as current implementations do)
lintDirectory(path).then(function (report) {
  console.log(report); // [...] array of lint reports
});

// Lazy promises
lintDirectory(path).then(function (readdirPromise) {
  readdirThenPromise.then(function (readFileMapPromise) {
    readFileMapPromise.then(function (lintMapPromise) {
      lintMapPromise.then(function (reports) {
        console.log(report); // [...] array of lint reports
      });
    });
  })
});

Technically it will no longer be aid for asynchronicity, as you would need to walk around interim promises that are part of process but are totally not relevant to result you are after.

So either you need to hack lintDirectory internally so it returns promise that resolves with reports immediately (not very clean), or as in above example, process result until you get to final value. Mind also that lintDirectory should be treated as black box, we shouldn't depend in any way on internal logic of how reports are gathered, so from that point it's also unacceptable solution.

Try to write practical but not too trivial script, at first steps you would feel you expect promises to flatten.

@medikoo I'm actually building this under the umbrella name "promised builtins". For me it makes sense. It's an alluring prospect. In some cases, it would remove the need for me to call "then" - ever. I could just do:

I'm not sure did you get my point correctly. My comment about array's was totally detached from promises, just to show from other perspective, that such idea doesn't make sense.

@junosuarez
Copy link

As @ForbesLindesay has asked and @Twisol has reminded us, can we please keep this (and the complementary against gist) to an inventory of arguments and postpone a discussion to a later date?

@Twisol
Copy link

Twisol commented Apr 16, 2013

I don't really understand why the Parametricity argument invokes "circular reasoning", so let me try to elucidate it a bit more.

Parametricity says, if you have a generic type Foo<A>, then no specific A shall be given special treatment. Intuitively, this lets you reason more effectively about Foo because there are no special cases to be aware of. That's all. Otherwise you could have situations where [Int] and [Char] are treated differently, because length :: [Char] might count a multi-byte Unicode sequence as only 1 for the length. That's non-obvious and creates an unfortunate coupling between the generic type and its parameter. You would want a new type UnicodeString to wrap [Char] and provide the additional semantics.

@myrne
Copy link

myrne commented Apr 16, 2013

@jden @ForbesLindesay I'm sorry. I've been out of line here. I'll leave it up to @ForbesLindesay . If he wants so, I'll remove my comments to reduce noise. Same applies for the other gist.

@erights
Copy link

erights commented Apr 17, 2013

If promises are always flattened, it is impossible to maintain this lazyness.

Q.makeRemote, defined at https://code.google.com/p/es-lab/source/browse/trunk/src/ses/makeQ.js#468 , modeled on http://wiki.erights.org/wiki/Proxy , is an existence proof that laziness is possible in a secure promise system that never allows a promise-for-promise. This is not a pleasant API for laziness nor is it meant to be. I offer it only as an existence proof, since it enables laziness to be expressed.

@briancavalier
Copy link

Promises for remotes may qualify here. A promise for a remote can provide itself (or an equivalent promise? I'll defer to @erights here) as its fulfillment value.

@rektide
Copy link

rektide commented Jun 6, 2013

Pro: a resolved promise-for-promise may have other slots besides .then and we may want to see those additional items. If we flatten promises, we may be skipping additional state they have.

@rektide
Copy link

rektide commented Jun 6, 2013

Pro promises-for-promises:

cost of implementing other spec if promises-for-promises allowed: very very very low.
cost of implementing flattening spec if promises-for-promises selected: impossible.

Given how easy it is to make flattening a library function, (a .thenAtLast) and given it's heavily heavily destructive nature, this is one of those golden cases where doing less, where not forcing everyone into some janky ass choice is the dumb, obvious & correct choice.

@rektide
Copy link

rektide commented Jun 6, 2013

Pro, in the abstract- control-flow representation:

In the abstract camp, I consider a promise as a control-flow construct. Put simply, a promise is a value for the output of a process. Sometimes that output is value signing the output of yet another process (a promise-for-promise).

That there is perhaps maybe data being distilled through these processes is somewhat ancillary; that data-flow perspective is important only if it happens to be the data that is important to you. This is a particular view some developers may adopt when using promises, but it is a subset, a more limited perspective of what a promise may be good for than a control-flow notion: it ignores the processes and skips straight to then, thinking that is the only value of promises.

@abuseofnotation
Copy link

I don't understand the 'Remote Promises' argument. What does the remote object from the example have to do with promises? To me it seems like a general object.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment