Observable Promise

An ObservablePromise is a normal Promiseopen in new window with some additional functionalities. The main one is the addition of a progress event type that allows you to send any number of events before the Promise`s resolution/rejection.

You can find it in the @banquette/promise package.

But why?

Promises are part of modern web browsers natively and very lightweight for browsers that don't support it. They are easy to use and yet provide enough to fulfill most asynchronous use cases.

But the weak point of a Promise resides in its mono event paradigm. You can only return one result by calling either:

  • resolve() when the processing as ended with success
  • reject() when an error is encountered

This can quickly become a limitation. Take for example the HttpService, here is an example of a file upload only using the basic functionalities of the promise:

http.post('/upload', {file}).promise.then(() => {
    // Called when the upload is fully done and the server has responded
}).catch((error) => {
    // Called if an error occurs
});

NOTE

HttpService methods return an HttpResponse<T> object, not directly the promise, thus the .promise.then().

You don't get any feedback on the request's progress. The service is a black box only giving some feedback when the request has completed, either with success or error.

Introducing observable promises

This progression problem can be solved using observables (like RxJSopen in new window). It's great but add a dependency to the project and feel a bit like using a sledgehammer to kill a gnat.

RxJS comes with much more features than this, features that we don't need in our example. And to be fair, that we don't need in any part of Banquette.

That's the reason for this small replacement of the native Promise implementation.

It complies to the Promises/A+open in new window specification and supports the async/await syntax:

function wait(delay: number): ObservablePromise {
    return new ObservablePromise((resolve, reject) => {
        window.setTimeout(resolve, delay);
    });
}

// This will print `Starting.` immediately and `1 second passed.` 
// one second later, like any `Promise` would.
async function test() {
    console.log('Starting.');
    await delay(1000);
    console.log('1 second passed.');
}
test();

For now nothing changed. But the executor method takes a third parameter: progress. You can call it any number of time to notify of the progression.

Let's imagine a wait() function that notifies when the timer is halfway done:

function wait(delay: number): ObservablePromise {
    return new ObservablePromise((resolve, reject, /* --> */ progress /* <-- */ ) => {
        progress('Starting timer.');
        window.setTimeout(() => {
            progress('Halfway there!');
        }, delay / 2);
        window.setTimeout(resolve, delay);
    });
}

You can then get these events using the progress method on the ObservablePromise:

wait(1000).progress(console.log).then(() => {
    console.log('1 second passed.');
});

// Will print:
// Starting timer.   (immediately)
// Halfway there!    (after 500ms)
// 1 second passed.  (after 1s)

It would also work in an async function using the async/await syntax:

async function test() {
    await wait(1000).progress(console.log);
    console.log('1 second passed.');
}
test();

// Will print:
// Starting timer.   (immediately)
// Halfway there!    (after 500ms)
// 1 second passed.  (after 1s)

Events replay

As defined in the specification, the promise will still call then() or catch() if the promise is already settled when you bind to it. Consider the following example:

Promise.resolve(true).then((result) => {
    // result contains: true
});

The then callback is called with the result even though the promise is already resolved when then() is called. It would behave the same way in case of error:

Promise.reject(new Error('Failed.')).catch((reason) => {
   // reason contains: Error('Failed.')
});

It doesn't matter when the callbacks are bound, the resolution event will always replay:

const promise = Promise.resolve(10);
window.setTimeout(() => {
    promise.then((result) => {
        // result contains: 10
    });
}, 1000);

It behaves the same way for progression events. The whole stack of events is replayed (in the order they have been generated) each time progress() is called:

function countToDelay(delay: number, step: number): ObservablePromise<string|number> {
    return new ObservablePromise((resolve, reject, progress) => {
        let acc = 0;
        const next = () => {
            acc += step;
            if (acc >= delay) {
                return void progress('Done.');
            }
            progress(acc);
            window.setTimeout(next, step);
        };
        progress('Starting.');
        window.setTimeout(next, step);
    });
}
countToDelay(1000, 100).then(console.log).progress(console.log);

// Will print:
// Starting.    (immediately)
// 100          (after 100ms)
// 200          (after 200ms)
// 300          (after 300ms)
// 400          (after 400ms)
// 500          (after 500ms)
// 600          (after 600ms)
// 700          (after 700ms)
// 800          (after 800ms)
// 900          (after 900ms)
// Done.        (after 1s)

Again, the whole stack of events is replayed even after the promise is resolved:

const promise = countToDelay(1000, 100);
promise.then(() => {
    promise.progress(console.log).then(console.log);
});

// Will print the same output as the previous example.

Selective catch

ObservablePromise also adds a new method called catchOf() that will only fire its callback if the type of error matches what is given as argument:

ObservablePromise.Reject(new Error('Test error.'))
    .catchOf(CancelException, (reason: CancelException) => {
        // Executed if the error is of type "CancelException"
    }).catchOf([Error, Exception], (reason: Error|Exception) => {
        // Called if reason is of type "Error" OR "Exception"
    }).catch((reason: any) => {
        // Called if no other catch method match
    });

The first catch method to match will stop the chain.

NOTE

Even if the behavior may seem weird if you're familiar enough with how promises work, please note that catchOf() still creates a new Promise. It simply throws again if the type condition is not met.

WARNING

The order is important! Doing the following will make the catchOf() callbacks unreachable:

ObservablePromise.Reject(new Error('Test error.'))
    .catch((reason: any) => {
        // Will always be called on error
    }).catchOf(CancelException, (reason: CancelException) => {
        // Is unreachable
    }).catchOf([Error, Exception], (reason: Error|Exception) => {
        // Is unreachable
    });

You could argue that you can reach the catchOf() callbacks by throwing again in catch(). It's true by that defeats the point of catchOf().

In some cases it may be useful to filter on errors that don't match a certain type. You can use catchNotOf() for that:

ObservablePromise.Reject(12)
    .catchNotOf(Error, () => {
        // Will be called if the error is NOT an instance of Error
    });

catchOf() and catchNotOf both also accept an array of types as filter.

Utilities

The principal functionality provided by ObservablePromise is its support of progression events, but there are also some other utility methods that could be useful in some situations.

Cancel

You can cancel a pending promise by calling cancel(). That will have the effect of rejecting the promise with a CancelException object as reason.

const promise = new ObservablePromise<number>((resolve: ResolveCallback<number>, reject) => {
    window.setTimeout(resolve, 100);
}).then(() => {
    console.log('Resolved!');
}).catch(() => {
    console.log('Canceled!');
});
promise.cancel();

// Will output:
// Canceled!

You can easily test for cancellation by doing:

import { CancelException } from '@banquette/promise';

const promise = new ObservablePromise(() => {
    // ...
}).catch((reason) => {
    if (reason instanceof CancelException) {
        console.log('Canceled!');
    }
});

Calls to cancel() are ignored once the promise have been settled.


Resolve

Create and immediately resolves the promise:

ObservablePromise.Resolve(10).then((result) => {
    // result contains: 10
});

Reject

Create and immediately rejects the promise:

ObservablePromise.Reject(new Error('Fail!')).catch((reason) => {
    // reason contains: Error('Fail!')
});

TIP

You will find a Reject method as well as a reject method in the object.

This is because the Promise specification ask for a reject method with a lowercase r, while in Banquette all static methods begin with an uppercase letter.

It's just an alias, there is no difference in calling one or the other.

The same rule apply for resolve.


All

Takes any number of arguments, of any types, and returns an ObservablePromise waiting for all of them to either not be Thenable or be settled.

NOTE

The items of the collection are not forced to be promises, any value can be given, non thenable items will resolve immediately.

ObservablePromise.All([
    ObservablePromise.Resolve(1),
    ObservablePromise.Resolve(2),
    3
]).then((collection: any[]) => {
    // collection contains: [1, 2, 3]
});

If any Thenable rejects, the whole promise rejects and catch() is called with the rejection value of the item that failed.


Any

Wait for the first promise to resolve or reject and forward the result to the promise returned by the function.

NOTE

The items of the collection are not forced to be promises, any value can be given, non thenable items will resolve immediately.

ObservablePromise.Any<number>([
    new ObservablePromise((resolve: ResolveCallback<number>) => {
        window.setTimeout(resolve, 15);
    }),
    ObservablePromise.Resolve(1)
]).then((result: number) => {
    // result contains: 1
});

ResolveAfterDelay

Resolve with a given value after a delay in milliseconds.

ObservablePromise.ResolveAfterDelay(400, 'test').then((result) => {
    // Called after 400ms
    // result contains: 'test'
});

MinDelay

Ensure the promise is resolved after a minimum amount of time. If the promise resolves sooner, a timer will wait for the remaining time.

ObservablePromise.MinDelay<void>(200, (resolve) => {
    window.setTimeout(resolve, 50);
}).then(() => {
    // Will be called after 200ms
})
ObservablePromise.MinDelay<void>(200, (resolve) => {
    window.setTimeout(resolve, 500);
}).then(() => {
    // Will be called after 500ms
})