If you've used nodejs before then you know that packages are at the heart of this platform. Every day and every second there is either a new update or a new package published to the npm registry. The majority of these packages are reusable and extensible. The way they do this can be one of many ways but there's one common trait that they all share: They can be seen as templates that are waiting for you to execute them.
This post will go over the Template Design Pattern in JavaScript. We will understand more in detail the approach of this pattern and one scenario of when we should use it. We will also see a diagram of how the the structure look like "outside the box". And finally, we will implement the pattern in code so that by the end of this article you will be comfortable about templating in JavaScript.
How does the Template Pattern work?
When we are implementing this pattern a useful way to approach this is to think about the start phase of something and the end phase.
When we write functions the first thing we think about sometimes is to decide on its parameters and how variables will be initialized. Eventually we decide how to end that function.
What happens in the middle depends on the implementation.
This is similar to how the flow of the Template works.
In more official terms it's essentially a bare interface that is given to the consumer where they can implement one or more steps of the algorithm without changing the structure.
After they define those steps and follows execution, the "end" phase is reached, just like a basic function.
When is the Template Pattern needed?
It's most needed in scenarios where two functions have important similarities in an implementation or interface but share the same problem where they aren't able to reuse those similarities. This means that when there is an update to one of the function's implementation, the other function needs to update its implementation as well. This is a bad practice and eventually becomes unmaintainable if not dealt with.
This is where the Template Pattern comes in. It encapsulates those similarities in itself and delegates the responsibilities of the other parts out for those that derive and implement them themselves.
That way if there was a change to the implementation of the encapsulated parts all derived classes don't have to be involved in them.
How does the Template Pattern look like in code?
In this section we will be implementing the Template Pattern ourselves.
Like I mentioned before, this can be implemented in a lot of ways because the pattern in its implementation is closely relative to the problem it is addressing. However they all have the same objective when we look at it in a bigger perspective.
Let's pretend we are building a function that runs a series of "transform" functions on a collection of dates of any date format. These can look like this:
const dates = [
357289200000,
989910000000,
'Tue Jan 18 2005 00:00:00 GMT-0800 (Pacific Standard Time)',
new Date(2001, 1, 03),
new Date(2000, 8, 21),
'1998-02-08T08:00:00.000Z',
new Date(1985, 1, 11),
'12/24/1985, 12:00:00 AM',
new Date(2020, 6, 26),
'Tue May 15 2001 00:00:00 GMT-0700 (Pacific Daylight Time)',
1652252400000,
'2005-01-18T08:00:00.000Z',
new Date(2022, 7, 14),
'1999-02-01T08:00:00.000Z',
1520668800000,
504259200000,
'4/28/1981, 12:00:00 AM',
'2015-08-08T07:00:00.000Z',
]
Our function will implement the Template Pattern and our task is to define the base skeleton holding these "empty" placeholders:
reducer
transformer
finalizer
sorter
When objects are created and derive from one of them they can provide their own algorithm that will be run when our function executes.
The consumer will have to implement the reducer
as a function that takes an accumulator and a value and returns some accumulated result.
transformer
is a function that transforms and returns a value of any data type.
finalizer
takes in a value and also returns a value of any data type. But this time this value will be used to perform the final step.
The sorter
is a function that takes in one item in the first argument and another item in the second argument. This function is the same as how you would implement the function in the native .Array.sort
method.
Our function with the template implementation will be named createPipeline
and takes in those functions if provided by the caller. If the caller doesn't provide one or more of them we must substitute them with a default implementation so that our algorithm can still run:
function createPipeline(...objs) {
let transformer
let reducer
let finalizer
let sorter
objs.forEach((o) => {
const id = Symbol.keyFor(_id_)
if (o[id] === _t) transformer = o
else if (o[id] === _r) reducer = o
else if (o[id] === _f) finalizer = o
else if (o[id] === _s) sorter = o
})
if (!transformer) transformer = { transform: identity }
if (!reducer) reducer = { reduce: identity }
if (!finalizer) finalizer = { finalize: identity }
if (!sorter) sorter = { sort: (item1, item2) => item1 - item2 }
return {
into(initialValue, ...items) {
return items
.reduce((acc, item) => {
return reducer.reduce(
acc,
finalizer.finalize(transformer.transform(item)),
)
}, initialValue)
.sort((item1, item2) => sorter.sort(item1, item2))
},
}
}
This simple function is a template where callers can pass in their own algorithms. It allows them to choose not to pass in any implementation or allow them to pass in one or all of the 4 functions involved in the pipeline.
When they call the into
function with a collection of items, the next step is to immediately run all of them through the pipeline and are eventually accumulated into a new collection.
Something we often see from libraries that provide some form of template interface to consumers is that they try to make it as easy as possible to be worked with.
For example, the createStore
in the redux library provides several overloads that developers can work with for instantiation. This is a very useful thing to do and it improves their reusability but also demonstrates the nature of a template in practice.
Inside template pattern implementations when there is a strict flow that an algorithm requires it is usually hidden within the implementation like the createStore
in redux.
When we go back to our previous example we noticed something in these lines:
objs.forEach((o) => {
const id = Symbol.keyFor(_id_)
if (o[id] === _t) transformer = o
else if (o[id] === _r) reducer = o
else if (o[id] === _f) finalizer = o
else if (o[id] === _s) sorter = o
})
This was not required or had anything to do with our pipeline but because we created a helper to distinguish them we allowed the caller to pass in any of the transformer
,reducer
, finalizer
and sorter
functions in any order even though they need to be in order when it runs the functions.
So any of these calls all return the same exact result even though they are ordered differently:
console.log(getResult(reducer, transformer, finalizer, sorter))
console.log(getResult(transformer, reducer, finalizer, sorter))
console.log(getResult(finalizer, sorter, transformer, reducer))
console.log(getResult(sorter, finalizer, transformer, reducer))
In the internal implementation it doesn't work as expected if they were to be called in different orders because the sorter needs to be the final operation. The finalizer needs to be run before the final (the sorter) operation and the transformer needs to be run before the finalizer.
This is how the higher level implementation looks like:
function createFactories() {
const _id_ = Symbol.for('__pipeline__')
const identity = (value) => value
const factory = (key) => {
return (fn) => {
const o = {
[key](...args) {
return fn?.(...args)
},
}
Object.defineProperty(o, Symbol.keyFor(_id_), {
configurable: false,
enumerable: false,
get() {
return key
},
})
return o
}
}
const _t = 'transform'
const _r = 'reduce'
const _f = 'finalize'
const _s = 'sort'
return {
createTransformer: factory(_t),
createReducer: factory(_r),
createFinalizer: factory(_f),
createSorter: factory(_s),
createPipeline(...objs) {
let transformer
let reducer
let finalizer
let sorter
objs.forEach((o) => {
const id = Symbol.keyFor(_id_)
if (o[id] === _t) transformer = o
else if (o[id] === _r) reducer = o
else if (o[id] === _f) finalizer = o
else if (o[id] === _s) sorter = o
})
if (!transformer) transformer = { transform: identity }
if (!reducer) reducer = { reduce: identity }
if (!finalizer) finalizer = { finalize: identity }
if (!sorter) sorter = { sort: (item1, item2) => item1 - item2 }
return {
into(initialValue, ...items) {
return items
.reduce((acc, item) => {
return reducer.reduce(
acc,
finalizer.finalize(transformer.transform(item)),
)
}, initialValue)
.sort((item1, item2) => sorter.sort(item1, item2))
},
}
},
}
}
One of several key parts of the internal implementation are these lines:
Object.defineProperty(o, Symbol.keyFor(_id_), {
configurable: false,
enumerable: false,
get() {
return key
},
})
This makes our template "official" because it hides the identifier from being seen from the outside and only exposes createTransformer
, createReducer
, createFinalizer
, createSorter
, and createPipeline
to the consumer.
Another part that helps the template is the object above it:
const o = {
[key](...args) {
return fn?.(...args)
},
}
This helps to structure a fluent api that reads like english:
into(initialValue, ...items) {
return items
.reduce((acc, item) => {
return reducer.reduce(
acc,
finalizer.finalize(transformer.transform(item)),
)
}, initialValue)
.sort((item1, item2) => sorter.sort(item1, item2))
}
Lets pretend we are the consumer and we want to use this template on this collection of dates as we've seen earlier:
const dates = [
357289200000,
989910000000,
'Tue Jan 18 2005 00:00:00 GMT-0800 (Pacific Standard Time)',
new Date(2001, 1, 03),
new Date(2000, 8, 21),
'1998-02-08T08:00:00.000Z',
new Date(1985, 1, 11),
'12/24/1985, 12:00:00 AM',
new Date(2020, 6, 26),
'Tue May 15 2001 00:00:00 GMT-0700 (Pacific Daylight Time)',
1652252400000,
'2005-01-18T08:00:00.000Z',
new Date(2022, 7, 14),
'1999-02-01T08:00:00.000Z',
1520668800000,
504259200000,
'4/28/1981, 12:00:00 AM',
'2015-08-08T07:00:00.000Z',
]
We have some issues:
- They are in different data types. We want them all to be in ISO date format.
- They are not sorted. We want them all to be sorted in ascending order.
We can use the code that implements the template design pattern to solve these issues so that we can get an ordered collection of dates in ISO format:
const isDate = (v) => v instanceof Date
const toDate = (v) => (isDate(v) ? v : new Date(v))
const subtract = (v1, v2) => v1 - v2
const concat = (v1, v2) => v1.concat(v2)
const reducer = factory.createReducer(concat)
const transformer = factory.createTransformer(toDate)
const finalizer = factory.createFinalizer(toDate)
const sorter = factory.createSorter(subtract)
const getResult = (...fns) => {
const pipe = factory.createPipeline(...fns)
return pipe.into([], ...dates)
}
console.log(getResult(reducer, transformer, finalizer, sorter))
console.log(getResult(transformer, reducer, finalizer, sorter))
console.log(getResult(finalizer, sorter, transformer, reducer))
console.log(getResult(sorter, finalizer, transformer, reducer))
It doesn't require much code and all of our executions return the same result:
[
"1981-04-28T07:00:00.000Z",
"1981-04-28T07:00:00.000Z",
"1985-02-11T08:00:00.000Z",
"1985-12-24T08:00:00.000Z",
"1985-12-24T08:00:00.000Z",
"1998-02-08T08:00:00.000Z",
"1999-02-01T08:00:00.000Z",
"2000-09-21T07:00:00.000Z",
"2001-02-03T08:00:00.000Z",
"2001-05-15T07:00:00.000Z",
"2001-05-15T07:00:00.000Z",
"2005-01-18T08:00:00.000Z",
"2005-01-18T08:00:00.000Z",
"2015-08-08T07:00:00.000Z",
"2018-03-10T08:00:00.000Z",
"2020-07-26T07:00:00.000Z",
"2022-05-11T07:00:00.000Z",
"2022-08-14T07:00:00.000Z"
]
Here is a diagram depicting our template:
And there you go!
Another Example
I like to use snabbdom to demonstrate concepts in several of my posts because it is short, simple, powerful and uses several techniques that are relative to the topics I wrote about in the past. Snabbdom is a front end JavaScript library that lets you work with a virtual DOM to create robust web applications. They focus on simplicity, modularity and performance.
They provide a module api where developers can create their own modules. They do this by providing to consumers a template that provides hooks that hook onto the lifecycle of a "patching" phase where DOM elements are passed around to life cycles. This is a simple but powerful way to work with the virtual DOM. It is a great example of one variation of a template pattern.
This is their template:
const myModule = {
// Patch process begins
pre() {
//
},
// DOM node created
create(_, vnode) {
//
},
// DOM node is being updated
update(oldVNode, vnode: VNode) {
//
},
// Patching is done
post() {
//
},
// DOM node is being directly removed from DOM via .remove()
remove(vnode, cb) {
//
},
// DOM node is being removed by any method including removeChild
destroy(vnode) {
//
},
}
Conclusion
And that concludes the end of this post! I hope you got something out of it and look out for more posts from me in the future!
Top comments (0)