4

I have a method for validating a string, I want that method to return a Promise as the validations being ran may be asynchronous. The issue I am having however is one of performance, I want the promise to resolve in the same event loop when possible (eg: when there are no asynchronous validations to be done) but I want the interface to remain consistent (eg: to always return a Promise).

The simplified code example below illustrates what I'm trying to do, but it incurs the aforementioned performance penalties because even when the validation can be performed synchronously it still waits for the next event loop to process the result.

In my specific use case this performance penalty is too high.

Below is a simplified (minimal) example of what I'm doing

// Array containing validation methods
const validations = [
  (value) => true, // Some validation would happen here
];
// Array containing asynchronous validation methods
const asyncValidations = []; // No async validations (but there could be)
const validate(value){
  // Run synchronous validations
  try {
    validations.forEach(validation => validation(value));
  catch(error){
    // Synchronous validation failed
    return Promise.reject();
  }
  if(asyncValidations){
    return Promise.all(asyncValidations.map(validation => validation(value));
  }
  // Otherwise return a resolved promise (to provide a consistent interface)
  return Promise.resolve(); // Synchronous validation passed 
}

// Example call
validate('test').then(() => {
  // Always asynchronously called
});

RobC
  • 22,977
  • 20
  • 73
  • 80
user1878875
  • 43
  • 1
  • 3
  • Promises may fulfill/reject synchronously but reacting to those things is always asynchronous. This is quite by design in order to avoid zalgo and inconsistent APIs in which order is different. – Benjamin Gruenbaum Aug 01 '19 at 10:41
  • @BenjaminGruenbaum Correct me if I'm wrong. I thought that the callbacks were called asynchronously only to prevent the call stack from blowing up. How would synchronous callbacks lead to inconsistency? – Aadit M Shah Aug 01 '19 at 11:14
  • @AaditMShah oh hey - long time no speak :] Basically "zalgo" - that would mean that for users of someApi().then(callback) the callback can get called either synchronously or asynchronously and zero, one or multiple times. Promises guarantee that it's always called asynchronously (rather than sometimes) and that it's called at most once. – Benjamin Gruenbaum Aug 01 '19 at 11:35
  • @BenjaminGruenbaum Oh, all right. I understand. Hence, if you always call it asynchronously then you don't have to worry about when it might sometimes be called synchronously and other times be called asynchronously. Although, this wouldn't be problem if promises were lazy like the `Cont` monad. You build the computation lazily and when you finally call it, it can be either synchronous or asynchronous depending upon the computation. IMHO, this is much cleaner than maintaining states like the promises A+ specification requires. Anyway, how are you doing? What are you up to these days? – Aadit M Shah Aug 01 '19 at 12:18
  • Ping me on Facebook or chat :) – Benjamin Gruenbaum Aug 01 '19 at 19:51

3 Answers3

5

You mention two different things:

  1. I want the interface to remain consistent

  2. [I want to] always return a Promise

If you want to avoid the asynchronous behaviour if it is not needed, you can do that and keep the API consistent. But what you cannot do is to "always return a Promise" as it is not possible to "resolve a promise synchronously".

Your code currently returns a Promise that is resolved when there is no need for an async validation:

// Otherwise return a resolved promise (to provide a consistent interface)
return Promise.resolve(); // Synchronous validation passed

You can replace that code with the following:

return {then: cb => cb()};

Note that this just returns an object literal that is "thenable" (i.e. it has a then method) and will synchronously execute whatever callback you pass it to. However, it does not return a promise.

You could also extend this approach by implementing the optional onRejected parameter of the then method and/or the the catch method.

str
  • 42,689
  • 17
  • 109
  • 127
  • Thanks str, that's effectively what I am trying to achieve and is probably the answer here but my concern would be that fulfilling the same interface provided by Promise would require a considerable effort. If I supported just '{then, catch}' would the resulting interface support being used by Promise.all for example? – user1878875 Aug 01 '19 at 10:06
  • Turns out it does still work with the other Promise APIs so this works perfectly :) thanks for the help. – user1878875 Aug 01 '19 at 10:35
3

The reason why promises resolve asynchronously is so that they don't blow up the stack. Consider the following stack safe code which uses promises.

console.time("promises");

let promise = Promise.resolve(0);

for (let i = 0; i < 1e7; i++) promise = promise.then(x => x + 1);

promise.then(x => {
    console.log(x);
    console.timeEnd("promises");
});

As you can see, it doesn't blow up the stack even though it's creating 10 million intermediate promise objects. However, because it's processing each callback on the next tick, it takes approximately 5 seconds, on my laptop, to compute the result. Your mileage may vary.

Can you have stack safety without compromising on performance?

Yes, you can but not with promises. Promises can't be resolved synchronously, period. Hence, we need some other data structure. Following is an implementation of one such data structure.

// type Unit = IO ()

// data Future a where
//     Future       :: ((a -> Unit) -> Unit) -> Future a
//     Future.pure  :: a -> Future a
//     Future.map   :: (a -> b) -> Future a -> Future b
//     Future.apply :: Future (a -> b) -> Future a -> Future b
//     Future.bind  :: Future a -> (a -> Future b) -> Future b

const Future =     f  => ({ constructor: Future,          f });
Future.pure  =     x  => ({ constructor: Future.pure,     x });
Future.map   = (f, x) => ({ constructor: Future.map,   f, x });
Future.apply = (f, x) => ({ constructor: Future.apply, f, x });
Future.bind  = (x, f) => ({ constructor: Future.bind,  x, f });

// data Callback a where
//     Callback       :: (a -> Unit) -> Callback a
//     Callback.map   :: (a -> b) -> Callback b -> Callback a
//     Callback.apply :: Future a -> Callback b -> Callback (a -> b)
//     Callback.bind  :: (a -> Future b) -> Callback b -> Callback a

const Callback =     k  => ({ constructor: Callback,          k });
Callback.map   = (f, k) => ({ constructor: Callback.map,   f, k });
Callback.apply = (x, k) => ({ constructor: Callback.apply, x, k });
Callback.bind  = (f, k) => ({ constructor: Callback.bind,  f, k });

// data Application where
//     InFuture :: Future a -> Callback a -> Application
//     Apply    :: Callback a -> a -> Application

const InFuture = (f, k) => ({ constructor: InFuture, f, k });
const Apply    = (k, x) => ({ constructor: Apply,    k, x });

// runApplication :: Application -> Unit
const runApplication = _application => {
    let application = _application;
    while (true) {
        switch (application.constructor) {
            case InFuture: {
                const {f: future, k} = application;
                switch (future.constructor) {
                    case Future: {
                        application = null;
                        const {f} = future;
                        let async = false, done = false;
                        f(x => {
                            if (done) return; else done = true;
                            if (async) runApplication(Apply(k, x));
                            else application = Apply(k, x);
                        });
                        async = true;
                        if (application) continue; else return;
                    }
                    case Future.pure: {
                        const {x} = future;
                        application = Apply(k, x);
                        continue;
                    }
                    case Future.map: {
                        const {f, x} = future;
                        application = InFuture(x, Callback.map(f, k));
                        continue;
                    }
                    case Future.apply: {
                        const {f, x} = future;
                        application = InFuture(f, Callback.apply(x, k));
                        continue;
                    }
                    case Future.bind: {
                        const {x, f} = future;
                        application = InFuture(x, Callback.bind(f, k));
                        continue;
                    }
                }
            }
            case Apply: {
                const {k: callback, x} = application;
                switch (callback.constructor) {
                    case Callback: {
                        const {k} = callback;
                        return k(x);
                    }
                    case Callback.map: {
                        const {f, k} = callback;
                        application = Apply(k, f(x));
                        continue;
                    }
                    case Callback.apply: {
                        const {x, k} = callback, {x: f} = application;
                        application = InFuture(x, Callback.map(f, k));
                        continue;
                    }
                    case Callback.bind: {
                        const {f, k} = callback;
                        application = InFuture(f(x), k);
                        continue;
                    }
                }
            }
        }
    }
};

// inFuture :: Future a -> (a -> Unit) -> Unit
const inFuture = (f, k) => runApplication(InFuture(f, Callback(k)));

// Example:

console.time("futures");

let future = Future.pure(0);

for (let i = 0; i < 1e7; i++) future = Future.map(x => x + 1, future);

inFuture(future, x => {
    console.log(x);
    console.timeEnd("futures");
});

As you can see, the performance is a little better than using promises. It takes approximately 4 seconds on my laptop. Your mileage may vary. However, the bigger advantage is that each callback is called synchronously.

Explaining how this code works is out of the scope of this question. I tried to write the code as cleanly as I could. Reading it should provide some insight.

As for how I thought about writing such code, I started with the following program and then performed a bunch of compiler optimizations by hand. The optimizations that I performed were defunctionalization and tail call optimization via trampolining.

const Future = inFuture => ({ inFuture });
Future.pure = x => Future(k => k(x));
Future.map = (f, x) => Future(k => x.inFuture(x => k(f(x))));
Future.apply = (f, x) => Future(k => f.inFuture(f => x.inFuture(x => k(f(x)))));
Future.bind = (x, f) => Future(k => x.inFuture(x => f(x).inFuture(k)));

Finally, I'd encourage you to check out the Fluture library. It does something similar, has utility functions to convert to and from promises, allows you to cancel futures, and supports both sequential and parallel futures.

Aadit M Shah
  • 72,912
  • 30
  • 168
  • 299
0

Technically it would be possible to access a function the exact same way when it returns a promise or something else:

function test(returnPromise=false) {
    return returnPromise ? new Promise(resolve=>resolve('Hello asynchronous World!')) : 'Hello synchronous World!'
}

async function main() {
    const testResult1 = await test(false)
    console.log(testResult1)
    const testResult2 = await test(true)
    console.log(testResult2)
}

main().catch(console.error)

You have to put all your code into any async function for that though. But then you can just use await, no matter if the function returns a promise or not.

Forivin
  • 14,780
  • 27
  • 106
  • 199