Icon made by Freepik from www.flaticon.com

Recently, I created a Node.js CLI to aid in one of the more tedious parts of my job. The CLI would monitor up to three different API endpoints for changes and, based on some criteria, alert me when information has changed. For example, I added a check to alert when a specific API value has changed since it last checked.

I wanted this tool to be modular where you can define individual “checks” to test for changes and mix and match as you please. I did not want to just fetch all the endpoints because I knew that would be a waste of time and resources depending on the provided checks.

For this, I used Promise.all to request only the required resources and assign them to the checks that needed them. In this article, I’ll describe this concept and the module that resulted from this, Promise Mate.

Since this was developed from a JavaScript project, I may use JavaScript terminology such as “promise”. However, the basic concept of this could potentially be applied to any language that has parallelism.

Rundown

This method is applicable when…

You have a pool of actions that depend on asynchronous results.

Some of those actions might depend on the same result.

Results should only be resolved once even when required by multiple actions.

Actions can/should wait for all resolutions (even the ones they don’t require) before running (as in JavaScript’s Promise.all ).

). It is either advantageous or inconsequential to run the actions sequentially after resolution (there are ways around this if not).

Reusability and/or modularity are concerns when it comes to actions and their associated asynchronous results.

Concept

Let’s create an example project to use as a framework to explain this concept.

Actions, resources, and the relations between the two.

We have four possible resources that we want to be resolved in parallel*: A, B, C, and D. We have four actions that we want to perform: 1, 2, 3, and 4. Those four actions have requirements based on our possible resources: actions 1 and 2 require resource A, action 2 requires resource B as well, action 3 requires resource C, action 4 does not require any resource, and resource D is not required by any action.

Any duplicate requirements will be reduced to only one request (ex. resource A will only be fetched once instead of twice). Additionally, unrequired resources will not be fetched (ex. resource D is not asked for so it will not be requested).

The resources are requested and, when all are resolved, they are passed to their requesting actions for processing.

* This does not have to be resource-fetching, it could be an action in themselves like posting data, saving files, etc. or even returning a constant variable. However, I will use “resource” throughout this document to stick with the verbiage introduced in this example project.

Putting the concept into practice

From this conceptual framework, I created the module Promise Mate (since the actions are “mates” with their promises and with each other). The following is how the concept was implemented within the module.

Resources

Using Promise Mate, you have to define how to fetch your resources. The definitions are a standard object with the key identifying the resource and the value being either a promise-generating function or a variable that will be passed to Promise.all as is.

const resources = {

A: () => fetch('A.txt').then(res => res.text()),

B: 'B',

C: () => Promise.resolve('C'),

D: Promise.resolve('D')

}

Actions

In Promise Mate, actions need to define their required resources (if any) and the callback function to call with those resolved resources. The standard format is as follows:

const action2 = {

requires: ['A', 'B'],

then(a, b) { console.log(a, b) }

}

The then function is the callback for when all the promises (including those not required by this particular action) are resolved. The required resources are passed as arguments to this function in the order that they were defined.

The requires property declares the required resources in order for the action to successfully run. Normally, it’s an array of strings which correspond to the keys for defined resources. When requesting only one resource, you can also just use the associated key without the array:

const action1 = { requires: 'A', then: (a) => { console.log(a) } }

If the requires property doesn’t have resolvable keys (they either are non-strings or are not defined), they are assumed to be constants to be passed to the callback.

const action3 = {

requires: [{ prefix: 'I found ' }, 'C'],

then: ({ prefix }, c) => { console.log(`${prefix}${c}`) }

} const action4 = {

requires: undefined, // Essentially the same as just not defining the requires property or as an empty array

then() { console.log('Check complete!') }

}

Tying it all together

You can now run everything like the following:

import Mate from 'promise-mate' Mate

.all(resources, [action1, action2, action3, action4])

.then(() => { console.log('All done!') }) // Console output:

// A

// A B

// I found C

// Check complete!

// All done!

Alternatively, you can take a more object-oriented approach for reusable and extendable resource definitions:

import Mate from 'promise-mate' const runner = new Mate(resources)

runner.define('C', () => Promise.resolve('B')) runner

.all([action1, action2, action3, action4])

.then(() => { console.log('All done!') })

Note that although the resources will be resolved asynchronously, the actions will be run sequentially once resolved.

Continuing or breaking the promise chain

Mate.all and Mate.prototype.all each return a promise that resolves with an array of whatever each action’s callback returns with (if anything). That way you could do something like the following:

runner

.all([action1, action2])

.then(([result1, result2]) => { console.log(result1, result2) })

If your actions might return promises, you can resolve them using Promise.all :

const promises = await runner.all([action1, action2])

const [result1, result2] = await Promise.all(promises)

console.log(result1, result2)

If an error or rejection is encountered in one promise, everything will be rejected (just like in Promise.all ).

runner

.all([action1, action2, action3, action4])

.catch((err) => { console.error(err) })

I suggest using p-reflect (or similar) if a promise isn’t necessary if it ends up getting rejected:

import reflect from 'p-reflect' runner.define('E', () => reflect(getText("dubious.txt"))) const action5 = {

requires: ['A', 'E']

then(a, e) {

if (e.isFulfilled) {

console.log(a, e.value)

} else {

console.warn(e.reason)

console.log(a)

}

}

} runner.all([action1, action5])

Conclusion

As stated before, this pattern is not applicable in every situation, but can be powerful in the situations in which it does.