JS Tip of the Day: A Custom Self-rejecting Promise

A Custom Self-rejecting Promise
Version: ES2015
Level: Advanced

The Promise API is pretty basic, not having a lot of bells and whistles already built in. Luckily you can extend Promise with your own class to add all the extra functionality you want. Here we’ll create a custom promise class that will automatically reject itself if the promise doesn’t resolve within 1 second showing off what it takes to extend Promise.

The tricky part in extending Promise is that in order to be able to resolve or reject the promise, you’ll need access to the resolve() and reject() functions passed into the constructor’s executor while at the same time not preventing users from providing their own. In order for this to work you need to create an executor wrapper that can intercept the resolve() and reject() functions while then also passing them on to any user-supplied executor.

class ExtendedPromise extends Promise {
    constructor (executor) {
        super((resolve, reject) => {
            // access resolve() and reject() here
            return executor(resolve, reject); // call user-supplied
        });
    }
}

With that, we can start our self-rejecting promise class by capturing reject() in this kind of wrapper and throwing it into a setTimeout() callback that will automatically reject the promise after 1 second.

class OneSecondPromise extends Promise {
    constructor (executor) {
        super((resolve, reject) => {
            setTimeout(()=> {
                reject(new Error('Too slow!'));
            }, 1000); // call reject after 1 second
            return executor(resolve, reject);
        });
    }
}

Since promises can only be fulfilled once, if code in the user-supplied executor calls resolve() or reject() after the timeout rejects, they will be ignored. Similarly, if user code calls resolve() or reject() before the timeout rejects, the reject() from the timeout will also be ignored (though you could [and probably should] go through the extra effort of intercepting these calls and also clearing the timer, but that’s not something we’ll bother with here).

new OneSecondPromise(resolve => {
    setTimeout(resolve, 500); // resolves before timeout rejects
}); // Ok

new OneSecondPromise(resolve => {
    setTimeout(resolve, 2000); // fails to resolve before timeout rejects
}); // Rejects w/ Error: Too slow!

And there you have it! Mostly.

While it seems the goal of creating a self-rejecting promise is complete, there is still more to consider. One thing in particular is how the promise species is used to create the new promises used in promise chains. This means that when starting a chain using a OneSecondPromise, every then(), catch(), and finally() used in that chain will create and return another OneSecondPromise. This can cause some unexpected results.

new OneSecondPromise(resolve => { // Ok
    setTimeout(resolve, 500);
})
.then(() => { // OneSecondPromise (but tied to returned promise so Ok)
    console.log('After 500 ms');
    return new Promise(resolve => {
        setTimeout(resolve, 2000);
    });
})
.then(() => { // OneSecondPromise (rejects)
    console.log('After 2000 ms');
});
/* results:
After 500 ms
Error: Too slow!
After 2000 ms
*/

This can be fixed by making the species of OneSecondPromise be the original Promise type. For that, we need to add a static Symbol.species property that has value Promise.

class OneSecondPromise extends Promise {
    static get [Symbol.species] () {
        return Promise; // chained promises will be of type Promise
    }
    constructor (executor) {
        super((resolve, reject) => {
            setTimeout(()=> {
                reject(new Error('Too slow!'));
            }, 1000);
            return executor(resolve, reject);
        });
    }
}

Now the same promise chain from before will run as expected since each then() returns a normal Promise rather than a self-rejecting one.

new OneSecondPromise(resolve => { // Ok
    setTimeout(resolve, 500);
})
.then(() => { // Promise
    console.log('After 500 ms');
    return new Promise(resolve => {
        setTimeout(resolve, 2000);
    });
})
.then(() => { // Promise
    console.log('After 2000 ms');
});
/* results:
After 500 ms
After 2000 ms
*/

Whenever extending Promise, this will be an important consideration. Especially for any Promise type that messes with resolving or rejecting the promise, you’ll likely want to make sure that a promise chain does not inherit those behaviors using species this way.

But we’re not done yet.

Additionally, being a newer type, Promise uses Symbol.toStringTag to identify itself in string conversions. To help better distinguish OneSecondPromise from other basic Promise promises - something notably helpful when debugging - a custom toString tag for OneSecondPromise should be implemented as well.

class OneSecondPromise extends Promise {
    static get [Symbol.species] () {
        return Promise;
    }
    get [Symbol.toStringTag] () {
        return 'OneSecondPromise'; // identifies as OneSecondPromise
    }
    constructor (executor) {
        super((resolve, reject) => {
            setTimeout(()=> {
                reject(new Error('Too slow!'));
            }, 1000);
            return executor(resolve, reject);
        });
    }
}

console.log(OneSecondPromise.resolve().toString());
// [object OneSecondPromise]

Lastly, it can be a little inconvenient to use the OneSecondPromise constructor when all you want to do is wrap another promise to have it self-reject after a second. To help with that we can take a cue from Array and add a static from() method which will act as a quick and convenient way create a OneSecondPromise from another promise.

class OneSecondPromise extends Promise {
    static get [Symbol.species] () {
        return Promise;
    }
    static from (wrapped) { // convenient for wrapping other promises
        return new OneSecondPromise((resolve, reject) => {
            wrapped.then(resolve, reject);
        });
    }
    get [Symbol.toStringTag] () {
        return 'OneSecondPromise';
    }
    constructor (executor) {
        super((resolve, reject) => {
            setTimeout(()=> {
                reject(new Error('Too slow!'));
            }, 1000);
            return executor(resolve, reject);
        });
    }
}

Now you can pass any promise into its from() to create a self-rejecting version of it!

OneSecondPromise.from(fetch(fastUrl))
    .then(reponse => {
        console.log('Success!'); // Success!
    });

OneSecondPromise.from(fetch(slowUrl))
    .catch(error => {
        console.log(error.message); // Too slow!
    });

Note, however, that the rejection from a OneSecondPromise wrapper like this doesn’t cancel the wrapped promise. It only injects a rejected promise after the wrapped promise in the chain. If, for example, you want to cancel the fetch() calls from above in addition to rejecting, you’d have to manually go through the process of doing that yourself, which is something to save for another time.

More info: