While working in a javascript project, we often had to do the same thing with promises over and over again:
Inspecting it and then doing some action based on the real content, while "doing" something could simply be doing nothing.
The customer project uses Q as promise library and we have built some language constructs on top of it to let it behave like a streaming API whenever we felt the necessity for it, but this time, we encountered, that a deeper promise integration would lead to a smoother API and overall experience.
Unfortunately, I can't reveal the implementation, but I can guide you through some conceptional ideas. So here we go.
promise.then (function (value) {
return ! value ? error (value)
: process (value)
})
// error, process -> functions to either reject or resolve the current step and to apply some magic
A classical if-else combination, here written with a ternary operator, or any other more complex if-elsif-else combination can be simply rewritten with the help of switch-cases statements to increase the readability. I do use switch-case under certain conditions, while others may not, but at least for this demonstration, it is very helpful.
promise.then (function (value) {
switch (value) {
case undefined : return error (value);
case 42 : return process (convert (value)));
default : return process (value));
}
});
Now let me rubber some parts and fill them up with pseudo code again.
promise.then (function (value) {
choice
when is.undefined (value) : return error (value)
when is.42 (value) : return process (convert (value)))
else : return process (value))
});
// is -> grouping of matcher functions
Beside from the keyword renaming, which should move a bit into the background, for now, there is a slight difference when it comes to the evaluation of a clause. A predicate function is used instead of an expression to determine which path should be executed next. So again, one more transformation, but this time from pseudo code back to javascript:
promise.choice ().when (is.undefined, reject)
.when (is.42, process . convert)
.else ( process)
.then ();
// process . convert -> composition of process and convert results in a new function
// process (convert (args ...))
It's quite likely that you have seen that pattern before and it's named a Content-based Router in the world of EIP (Enterprise Integration Patterns). Other frameworks like Apache Camel or Spring Integration provide a more in-depth API for this pattern, so called DSL (Domain Specific Language), to configure each path more meaningful.
A generalization of the code fragment brings us to a simple proposal with further conditions below:
promise.choice (config? : obj).when+ : choice-promise (predicate : fun, lambda : fun)
.else? : thenable-promise ( lambda? : fun)
.then? : thenable-promise (success : fun, error : fun)
// ? -> at most once
// + -> at least once
// : -> name, type discriminator for parameter and return definition
Behavior!
- execution order should follow the definition of switch-case
- choice could be applied on the fulfilled path by convention, but run on the rejected path or both by configuration
- when could be an intermediate operation
- when should use a lambda/function to provide call by name evaluation strategy (laziness..)
- else should be a terminal operation
- else should pass the current value untouched if used but the lambda/function is unset
- else (or default) branch could be fully omitted (only when is used), but the resulting promise shall be rejected if no when predicate matches the value additionally
- then should be a terminal operation
- promise shall be rejected if a predicate throws an exception
- promise shall be rejected if the matching lambda throws an exception
- promise shall be resolved with the return value of the matching lambda, which might be a promise
Else?
The decision to make choice an explicit operation has been done for the following reasons. One can see the beginning of the conditional routing (a named pattern) and it provides a central entry point for the configurational options - different concerns/aspects can be handled by a separate choice block afterward. It further allows us to break the chain if a default branch is omitted. But I guess one could imagine an implementation without choice at all, which would then result in when/else/then operations only. The latter form should rely on the conceptional API of then if we want to support executions on faulty code paths as well, which could then look like:
promise.when : thenable-promise (predicate : fun, success : fun, error : fun)
An implementation like that might have other limitations and may require null blocks or default objects to behave correctly as it combines different aspects in a single API signature. So generally spoken, I do prefer the formerly explained variation for the here mentioned reasons and an implementation for Q took me just a couple of hours and a screen size, including a testbench for the conditions above - well, more than a screen here :) but it's really nothing special. An implementation with when as an intermediate operation seems to be easier to accomplish.
At the end I came up with the following which can be used as a starting point if you like to do something similar with Q:
Q.choice = function (value, config) {
return Q (value).choice (config);
};
Q.makePromise.prototype.choice = function implementation (config) {};
Q.choice (42, config);
Q.resolve (42).choice (config);
Q.fcall (action).choice ()
.when (is.coordinate, navigate ())
.when (is.string, navigate () . georeverse)
.then (toast ('navigation in progress'), toast ('something went wrong'))
The photograph for this article was taken by Martin Abegglen.
Become a backer or share this article with your colleagues and friends. Any kind of interaction is appreciated.