Modern JavaScript Decorator Pattern
Most of the JavaScript examples of the Decorator Pattern out there are either too complex or disappointingly simple. Almost everything is an object in JavaScript, so decorating —in general— is not a challenging task, but it is hard to make it scalable.
TL; DR
Look at this codesandbox implementation of the decorator pattern applied to Error handling in JavaScript.
Context
While working on migrating one of our GraphQL endpoints I noticed that the error structure changed slightly. It wouldn’t normally be a problem, but each controller consuming the data had different error mappings for the clients (statusCode
, name
, code
, …) depending on the errors, making the migration incredibly difficult. Furthermore, some controllers were exposing data that could potentially become a security risk.
I decided to try and create a scalable solution and it took me a while, my first iterations used inheritance but I didn’t like the complexity and lack of flexibility, so I dusted off my copy of Head First Design Patterns (Freeman & Freeman) and found an implementation of the Decorator Pattern that uses composition at run time.
Note; I will add the Node Error Handling implementation in a different post, in this one I just want to talk about a modern implementation to the decorator pattern in JavaScript.
Solution
JavaScript doesn’t come with function overloading (at least not out of the box), but it is a dynamically typed language, and we can use this to our advantage to create a flexible signature for our error handler. So, to allow composing Errors we can pass dynamic decorators to the constructor of our class.
We can see two methods above:
constructor(error, …decorators)
It takes in an error as first argument which can be anything: an Object, an Error, an instance of DecoratedError
, or even a String. Anything after the first argument are decorators which can be Objects, Functions, and even Arrays of these.
There are two importan things here; first “idempotency”, it will make sure that you can execute the constructor as many times as you need on top of each other by calling the decorators with the original error to always generate the same output. Second, it decorates the instance with a default decorator followed by the rest of the decorators.
decorate(…decorators)
All decorators are computed in a first-in-first-out fashion, even if a decorator returns other decorators, they will be added to the beginning of the list to be processed in order.
The “decorate” method first checks for higher-order decorators (aka. Function
s) which can return other decorators like objects, arrays, and even other functions and are then added to the beginning of the list and computed in the next iteration.
Similarly, it checks for Array
s and flattens them to be computed in the next iteration. Lastly, it will augment the current instance with the data obtained from the decorators.
Notice that it also returns this
(the instance) at the end so that you can chain the responses if you wanted:
new DecoratedError('Oops! Something went wrong.')
.decorate({ bar: 'baz' })
.decorate({ foo: 'baz' })
Benefits
There are multiple advantages to the code above.
- We can pass N number of decorators.
- Decorators are idempotent; the same input should always yield the same output.
- It will automatically flatten decorators if we pass an
Array
. - The decorators can be
Object
s which are directly assigned to our instance. - You can use higher-order decorators (
Function
s) which take therawError
and theparsedData
as parameters and can return decorations (Object
s), multiple decorators (Array
s) or even higher-order decorators (Function
s). - You can further decorate instances by calling
error.decorate()
. - It filters out
undefined
properties from the decorations returned by the decorators. - You can use composition or inheritance to enhance the functionality of this
DecoratedError
.
Parsers vs Maps
We could make a distinction to facilitate the way we interact with this class by calling “parsers” the decorators that look at the raw data and “maps” the decorators that augment the error by looking at the parsed data.
In other words, parsers are types of errors taking care of parsing the raw data while error maps act as filters on top of the parsed data.
So, now you could have any number of types of errors (aka parsers):
const GRAPHQL_ERROR = (response) => {
const { statusCode, body = {} } = response || {}
const error = body.errors?.[0] || {}
const details = error?.extensions?.details?.[0] || {}
return {
status: statusCode >= 400 ? statusCode : 500,
name: error.name,
issue: details.issue,
}
}// somewhere else
const response = getUsersQuery()if(response.body.errors || response.statusCode >= 400) {
throw new DecoratedError(response, GRAPHQL_ERROR)
}
And even higher-order decorators (aka “maps” — Function
s not really Map
s) that match a property of the parsedData
and decorate the error accordingly:
// This function simply uses composition to match 'issue' or
// '_default' to a property of the object ("map") being passed.
const USER_ERRORS_MAP = (_, parsed) => ({
USER_NOT_FOUND: {
status: 400,
code: 'BAD_REQUEST'
},
USER_BLOCKED: SOME_OTHER_ERROR_MAP, // yes, even other maps
_default: { status: 500 }
}[parsed.issue || '_default'])// somewhere else
const response = getUsersQuery()if(response.body.errors || response.statusCode >= 400) {
throw new DecoratedError(response, GRAPHQL_ERROR, MAP_USER_ERRORS)
}
Conclusion
We have shown the absolute power of a modern dynamic decorator pattern applied to an error handling mechanism.
At the core of this implementation is the decorate
method that applies the decorators in the right order, and we can use this same implementation for more than just error handling, for example, we could replace “error” for “data” and use it for API responses instead of errors.
The idea is that the pattern is simple, we created a reusable class that can have any number of decorators and continue to be augmented further using composition:
new Whatever(data, ...decorators)
.decorate(...)
.decorate(...)
.decorate(...)
In this post, I showed that we can even enhance it to differentiate between data parsers and handlers (maps), where the parsers would consume the actual data (error in this case), while the handlers would consume the parsed data (after the decorators have been executed). But to be honest, we could extract the decorate method to a utility and use it for anything, not just error handling.
Look at Scalable Error Handling in Node if you are interested in knowing how I reused this pattern to handle Node errors.