JavaScript is a language that is very well known for its flexibility. You've probably heard people that say its one of JavaScript weaknesses or even some that say the total opposite. I tend to be more on the latter side because we tend to use this to our advantage to do amazing things that hardly seemed possible many years ago.
React is already a factual proof that backs that up as amazing tools were invented thereafter. There is also Electron which powers today's booming technology like Visual Studio Code and Figma.
Every JavaScript library uses some form of a design pattern nowadays which is also a hot topic in the modern JavaScript ecosystem. One design pattern that we will focus on in this post is the Strategy Design Pattern. And because JavaScript is so flexible it makes design patterns like the Strategy robust as we will see in this post.
In this post, we will be going over the Strategy Design Pattern. This is a well known pattern that encapsulates one or more strategies (or algorithms) to do a task. These encapsulated strategies all have the same signature so the context (the one who provides the interface) never knows when they are dealing with the same or different object (or strategy). This means that each strategy can be swapped together many times without our program ever realizing it during the lifetime of our app.
What kind of objects are involved?
In the Strategy pattern, these two objects are always involved:
- Context
- Strategy
The Context must always have a reference or pointer to the current strategy being used. That means if we have 200 strategies then it's optional that the other 199 are used. You can think of them as being "inactive".
The Context also provides the interface to the caller. The caller is the client. The caller can use any of the strategies to perform their work and they can also switch the current strategy with another strategy at any time on demand.
The actual Strategy implements the execution logic for itself that will be used when executed.
Strengths
In a normal function implementation the function is usually doing something and returns a value. In the Strategy Design Pattern when there is a base (Context) class and one Strategy it is like a function that calls the Strategy and returns the result (in other the words the same thing).
But when there are two or more strategies, the point is that the strategy can be one of many strategies controlled by the caller.
The major benefit here is that we can define as many strategies as we want and swap between each one to be used on demand without inflicting a single hint of change in behavior of code as long as the pattern is written the way it should.
Implementations of a Strategy can change but as long as they keep the same signature as expected by the context then there is no need to experience unnecessary changes to code.
Here is a diagram depicting this flow:
Implementation
Our first implementation will focus on fetching. We'll define a createFetcher
function that returns the interface to create fetchers. These fetchers can be spawned by the client and can be implemented however they desire as long as they take in a url, retrieve and returns its response.
We'll be using the axios request library, node's native https module and the node-fetch library to implement as one strategy each.
In total we will have 3 strategies:
const axios = require('axios').default
const https = require('https')
const fetch = require('node-fetch')
function createFetcher() {
const _identifer = Symbol('_createFetcher_')
let fetchStrategy
const isFetcher = (fn) => _identifer in fn
function createFetch(fn) {
const fetchFn = async function _fetch(url, args) {
return fn(url, args)
}
fetchFn[_identifer] = true
return fetchFn
}
return {
get fetch() {
return fetchStrategy
},
create(fn) {
return createFetch(fn)
},
use(fetcher) {
if (!isFetcher(fetcher)) {
throw new Error(`The fetcher provided is invalid`)
}
fetchStrategy = fetcher
return this
},
}
}
const fetcher = createFetcher()
const axiosFetcher = fetcher.create(async (url, args) => {
try {
return axios.get(url, args)
} catch (error) {
throw error
}
})
const httpsFetcher = fetcher.create((url, args) => {
return new Promise((resolve, reject) => {
const req = https.get(url, args)
req.addListener('response', resolve)
req.addListener('error', reject)
})
})
const nodeFetchFetcher = fetcher.create(async (url, args) => {
try {
return fetch(url, args)
} catch (error) {
throw error
}
})
fetcher.use(axiosFetcher)
Inside our createFetcher
function we created this line: const _identifer = Symbol('_createFetcher_')
This line is important because we want to ensure that each strategy created is actually a strategy otherwise our program will treat any passed in object as a strategy. It may sound like a positive benefit to have anything treated as a strategy but we would lose validity which makes our code more prone to errors which can easily deter our debugging experience if we misstep.
Symbol
returns to us a unique variable by definition. It is also hidden within the implementation of the context, so there is no way that objects created outside of our create
function will be treated as a strategy. They would have to use the method made publicly from the interface provided by the context.
When the client calls use
it's submitting axiosFetcher
to be the used as the current strategy and is then bound as a reference until the client swaps in another strategy via use
.
Now we have three strategies for retrieving data:
const url = 'https://google.com'
fetcher.use(axiosFetcher)
fetcher
.fetch(url, { headers: { 'Content-Type': 'text/html' } })
.then((response) => {
console.log('response using axios', response)
return fetcher.use(httpsFetcher).fetch(url)
})
.then((response) => {
console.log('response using node https', response)
return fetcher.use(nodeFetchFetcher).fetch(url)
})
.then((response) => {
console.log('response using node-fetch', response)
})
.catch((error) => {
throw error instanceof Error ? error : new Error(String(error))
})
Hurray! We've now seen how it can be implemented in code. But can we think of a situation in the real world where we need this? You can think of plenty actually! However if this is your first time reading about this pattern then I understand that it can be hard to think of a scenario beforehand unless we see one in practice first.
The examples we went over in this post shows the pattern implementation but anyone reading this can ask "Why bother implementing three fetcher strategies when you can just directly use one like axios to get the response and call it a day?"
In the upcoming example we will be going over a scenario where the Strategy Design Pattern is definitely needed.
Handling different data types
Where the strategy pattern shines most is when we need to handle different data types when doing something like sorting.
In the previous examples we didn't really care about any data types because we just wanted some response. But what happens when we receive a collection of something and needed to do some narrow task like categorizing them? What if they need to be sorted correctly?
When we need to sort several collections where each are a collection of another data type we can't just use the native .sort
method on all of them because each value can be treated differently in terms of "less" and "greater".
We can use the Strategy Pattern and define different sets of sorting algorithms that are readily available in the runtime so that we can use them interchangeably on demand.
Consider these collections:
const nums = [2, -13, 0, 42, 1999, 200, 1, 32]
const letters = ['z', 'b', 'm', 'o', 'hello', 'zebra', 'c', '0']
const dates = [
new Date(2001, 1, 14),
new Date(2000, 1, 14),
new Date(1985, 1, 14),
new Date(2020, 1, 14),
new Date(2022, 1, 14),
]
// Need to be sorted by height
const elements = [
document.getElementById('submitBtn'),
document.getElementById('submit-form'),
...document.querySelectorAll('li'),
]
We can create a Sort
strategy class and a Sorter
context class.
Note that they don't need to be classes. We're just choosing to use classes now to diversify the implementation a little:
const sorterId = Symbol('_sorter_')
class Sort {
constructor(name) {
this[sorterId] = name
}
execute(...args) {
return this.fn(...args)
}
use(fn) {
this.fn = fn
return this
}
}
class Sorter {
sort(...args) {
return this.sorter.execute.call(this.sorter, ...args)
}
use(sorter) {
if (!(sorterId in sorter)) {
throw new Error(`Please use Sort as a sorter`)
}
this.sorter = sorter
return this
}
}
const sorter = new Sorter()
It's pretty straight forward. Sorter
keeps a reference to the Sort
that is currently being used. This is the sort function that will be picked up when calling sort
. Each Sort
instance is a strategy and passed into use
.
The Sorter
does not know anything about the strategies. It does not know that there is a date sorter, number sorter, etc. It just calls the Sort's execute method.
However the client knows about all of the Sort
instances and controls the strategies as well as the Sorter
:
const sorter = new Sorter()
const numberSorter = new Sort('number')
const letterSorter = new Sort('letter')
const dateSorter = new Sort('date')
const domElementSizeSorter = new Sort('dom-element-sizes')
numberSorter.use((item1, item2) => item1 - item2)
letterSorter.use((item1, item2) => item1.localeCompare(item2))
dateSorter.use((item1, item2) => item1.getTime() - item2.getTime())
domElementSizeSorter.use(
(item1, item2) => item1.scrollHeight - item2.scrollHeight,
)
With that said, its entirely up to us (the client) to handle this accordingly:
function sort(items) {
const type = typeof items[0]
sorter.use(
type === 'number'
? numberSorter
: type === 'string'
? letterSorter
: items[0] instanceof Date
? dateSorter
: items[0] && type === 'object' && 'tagName' in items[0]
? domElementSizeSorter
: Array.prototype.sort.bind(Array),
)
return [...items].sort(sorter.sort.bind(sorter))
}
We now have a robust 15 line function that can sort 4 different variations of collections!
console.log('Sorted numbers', sort(nums))
console.log('Sorted letters', sort(letters))
console.log('Sorted dates', sort(dates))
And that is the power of the Strategy Design Pattern in JavaScript.
Thanks to the nature of JavaScript treating functions as values this code example blends in that capability to its advantage and works with the Strategy pattern seamlessly.
Conclusion
And that concludes the end of this post! I hope you found this to be useful and stay tuned for more useful tips in the future!!
Find me on medium
Top comments (0)