In this article, we explore how to extend async JavaScript functions using a functional design pattern—the decorator.
We explain what function decorators are in general, then we explore why they are especially useful for extending async JavaScript functions. Finally, we describe decorators that:
- Lock a function so that it can only have one outstanding call at a time (useful for debouncing superfluous clicks from impatient users).
- Synchronize a set of function calls to avoid race conditions (useful when interacting with shared resources, e.g. to ensure a PUT is used instead of a POST).
- Retry API calls in a transparent way (useful when dealing with unstable third party integrations or bad internet connections).
- Memoize the result of a previous call (not specific to async-functions).
We assume that you understand how ES6 promises work.
Note: The next version of the ECMA standard may introduce a keyword for defining async functions, but for now when we refer to an async function we mean any function that returns promises. In other words, any function whose return-value is resolved asynchronously.
Function decorators 🔗
The term “decorator” originated in Python. Originally it referred to a particular syntax that enabled a common functional pattern in Python 1, however, the term has since developed a more general meaning.
A function decorator takes a function as an argument, and returns a new function with extended, or decorated, behavior 2. The original function is called the “decoratee” and the returned function is said to be “decorated” with the new functionality.
Here is a simple but useless example of a function decorator:
const multiplySecondParamDecorator = (decoratee, multiplier) => {
return (a, b, ...args) => decoratee(a, b*multiplier, ...args);
};
const add = (a, b) => a + b;
const decoratedAdd = multiplySecondParamDecorator(add, 5);
console.log(decoratedAdd(1, 2)); // 11
The decorated function alters the input before passing it along to the decoratee.
Decorators can alter the input of the decoratee, the output, or selectively bypass the decoratee altogether. Decorators can be layered.
Most decorators act like reusable function middleware.
The banner image is a diagram of a function that has been decorated twice. The invoker must pass control through each decorator before reaching the original function. The solid line indicates the normal flow from the invoker through each decorator and back again. The dashed line indicates an alternate flow of control wherein the first decorator determines it does not need to invoke its decoratee (perhaps it has a cached copy of the return value).
Function decorators provide a convenient way to reuse function invocation logic. Decorators are especially reusable when the interface between the decorator and the decoratee is generic. The multiplySecondParamDecorator
can only be used with functions whose second argument is always a number, while the enforceReturnTypeDecorator
could be used with just about any function.
Decorating arrows and functions 🔗
The first example decorated an arrow functions. If you want your decorator to work with generic functions, be sure to bind this
. Here is our enforceReturnTypeDecorator
decorator, re-written to work with arrow functions and regular functions:
const enforceReturnTypeDecorator = (decoratee, Type) => {
return function decorated(...args) {
const returnValue = decoratee.apply(this, args);
if (returnValue instanceof Type) {
return returnValue;
} else {
throw new Error('Invalid return type');
}
};
};
Promises make decorations possible 🔗
Decorators are especially useful for extending async functions.
Before diving into our examples, it is worth noting that without promises it would be much more difficult to decorate asynchronous functions.
Asynchronous functions that use callbacks must define an interface for passing in these callbacks, and the invoker of the function must be aware of this interface. Here are a few alternate interfaces for setting up callbacks:
asyncCall1(onSuccess, optionalOnFailure);
const options = {
success: onSuccess,
failure: onFailure,
};
asyncCall2(options);
asyncCall3(a, b, options);
asyncCall4(a, b).on('success', onSuccess);
asyncCall5(a, b).success(onSuccess);
Decorators for any of these functions would be tightly coupled to the particular callback interface, and would thus be less reusable. In contrast to these examples, promises create a uniform callback interface and thus provide the opportunity for more generic decorators.
The remainder of this article describes four decorators for working with promise-based async functions.
Lock decorator 🔗
In single page web applications, clicking a button will often trigger asynchronous AJAX requests to retrieve data to render the subsequent “page” of the application. If the requests take longer than expected, the user will become impatient and click the button again, triggering more requests. This behavior is almost guaranteed to occur if there is no indication to the user that the application is waiting.
One way to prevent these unwanted duplicate requests is to lock the click event handler such that only a single simultaneous call can occur at a time. The decorator would reject subsequent calls immediately.
A versatile way to apply this pattern is to use a decorator:
const lockDecorator = (decoratee) => {
const decorated = (...args) => {
var promise;
if (decorated.locked) {
promise = Promise.reject("existing call in progress");
} else {
decorated.locked = true;
promise = decoratee(...args);
promise.then(unlock, unlock);
}
return promise;
};
decorated.locked = false;
const unlock = () => {
decorated.locked = false;
}
return decorated;
}
This decorator can lock any async function while there are outstanding calls. It also exposes the locked state so that the UI can indicate to users that the application is waiting. This locking logic is frequently duplicated in various controllers throughout an application.
Synchronize decorator 🔗
JavaScript is single-threaded, so it does not have real concurrency. However, due to its non-blocking, asynchronous nature, it has pseudo-concurrency and thus race conditions.
Concurrency issues are less common in client-side code than in server-side code, but they still can occur. For example, imagine that you have a resource that, when saved the first time, makes a POST request to a resource. Subsequent calls should make a PUT request using the id that was returned from the first call. If the object is saved a second time before the first call completes, it will trigger a second POST, creating two objects in the API. Another situation that can require synchronization is when multiple API calls must be strung together—it can be useful to ensure that all of the API calls occur as a single “transaction” from the client-side when it is not possible to handle this elegantly on the server side.
A useful way to deal with race conditions is to ensure that only one call to a resource can occur at a time. A simple way to do this is to have a queue of function calls and to invoke a call only after the previous calls have completed. This pattern can be abstracted away into a decorator very cleanly as follows:
const synchronizeDecorator = (decoratee) => {
const promiseQueue = [];
const dequeue = () => {
promiseQueue.splice(0, 1);
};
return (...args) => {
let promise;
const invokeDecoratee = () => decoratee(...args);
if (promiseQueue.length === 0) {
promise = invokeDecoratee();
} else {
promise = promiseQueue[promiseQueue.length - 1]
.then(invokeDecoratee, invokeDecoratee);
}
promiseQueue.push(promise.then(dequeue, dequeue));
return promise;
};
};
Retry decorator 🔗
Modern web applications often communicate with a variety of different services. Sometimes, these services are temporarily unavailable. Sometimes it is useful to retry calls to these services again after a delay. For example, you may want your application to attempt to post analytics info a few times before giving up.
A decorator can be used to simply and cleanly implement retry functionality for asynchronous functions.
const retryDecorator = (decoratee, retryIntervals) => {
return (...args) => {
return new Promise((fulfill, reject) => {
const reasons = [];
const makeCall = () => {
decoratee.apply(...args)
.then(fulfill, (reason) => {
let retryIn = retryIntervals[reasons.length];
reasons.push(reason);
if (retryIn !== undefined) {
setTimeout(makeCall, retryIn);
} else {
reject(reasons);
}
});
};
makeCall();
});
};
}
Memoize decorators 🔗
A common caching pattern in web applications is to:
- retrieve the initial state of the collection from the server when the collection is first needed
- update the client-side copy of the collection in tandem with the API requests that update the persistent server-side copy.
If there are several elements on a “page” that depend on this collection, when you load the page each element will attempt to retrieve the initial server-side copy of the collection. In order to avoid making duplicate requests, we can share the first promise among all of their callers.
const memoizeDecorator = (decoratee) => {
let promise = null;
return (...args) => {
if (promise === null) {
promise = decoratee(...args);
}
return promise;
};
}
As is indicated by the decorator’s name, this is memoization with a single value 3. The key idea is that, even if this collection is initialized many times when the page loads, only a single AJAX request will be made, but all calls will resolve when the original promise is fulfilled. Furthermore, calls made after the original request returns will fulfill immediately.
Conclusion 🔗
Promises are a great tool for simplifying asynchronous code; promises allow developers to abstract away function invocation logic into reusable function decorators. These decorators can be conceptualized as “function middleware.”
The decorators described here have been useful in real-world applications. Here are some more ideas for useful function decorators:
- Lock a function if there are more than N currently running calls.
- Invoke the function using a pool of web-workers.
- Synchronizes a group of related functions (here is an example of this pattern being used in server-side JavaScript).
-
Synchronizes a function, and clears the call queue on failure.
- Memoizes a function but expires the cached copy after a certain length of time.
To see the above code snippets along with tests for each decorator, check out our example script. Drop it in Node and try it for yourself!
This article was updated on 8 May 2017 to use ES6 syntax.
References 🔗
- html5rocks – A great introductory article with some nice diagrams.
- Mozilla Developer Network Article – Concise introduction to promises with some web development examples.
- SLaks Blog – Good discussion of promises error-handling best practices.
- ECMA6 Specification – The detailed semantics of how ECMA6 Promises work.
- Simple ES6 Promise Polyfill – A small, readable polyfill for ES6 promises.
Footnotes 🔗
-
There are a few exceptions to this definition of a function decorator. Sometimes a decorator may not return a function, e.g. in Python the builtin
property
function returns a Python descriptor. Other times the decorator may return the original function, but will register it with some global store. E.g. the powerful and popular pytest library uses decorators to register testing fixtures. Both of these exceptions break the model of a descriptor acting as “functional middleware,” however in our experience the vast majority of decorators are used as middleware, and due to the lack of a better name we decided to use the term “decorator” in a more specific sense in this article. ↩ -
Utility libraries like underscore come with a standard memoization decorator which will avoid calling the underlying function if the function has already been invoked with the same arguments; this should work just as well with promises as it would with any other value. ↩