Adapter Pattern in JavaScript
October 29th, 2020
Have you ever come across code where some function was suffixed with Adapter
? Chances are great that you were reading code incorporating compatibility in behavior between two interfaces using the Adapter Pattern. This post will go over the Adapter Pattern in JavaScript and explain why the pattern is important and why it can be beneficial modern programming.
The way the Adapter works is that you create a function (which can be a class, but it doesn't have to be implemented as a class) that adapts its interface's properties and methods into a new class, seamlessly as if the adapter can still be identified and behave like the original while at the same time being able to work with the new interface.
As web technology in JavaScript is constantly evolving, it's easy to see this pattern used in open source projects today like axios-mock-adapter for example.
There's no right way to write an adapter pattern in JavaScript. However they all share the same goal--to provide compatibility from one interface to another.
For example, if we had a function that takes in a list of objects (which we'll call each as action objects in the upcoming example), and converts them to Action
instances into an array before passing them to another function that calls every one of their execute
method, then any new introductions to implementation should follow that similar interface if they want to maintain compatibility.
For example, lets take these action objects that share a common actionType
property:
const pageJumpActionObject = {
actionType: 'pageJump',
destination: '/about',
}
const refreshPageActionObject = {
actionType: 'refresh',
}
const saveActionObject = {
actionType: 'save',
data: { fruits: ['apple'] },
}
const actions = [
pageJumpActionObject,
refreshPageActionObject,
saveActionObject,
]
Now given these classes we define:
class Action {
constructor(action) {
this.action = action
}
}
class PageJumpAction extends Action {
constructor(action) {
super(action)
this.destination = action.destination
}
execute() {
console.log(`Redirecting to: ${this.destination}`)
}
}
class SaveAction extends Action {
constructor(action) {
super(action)
this.data = action.data
}
generateFormData() {
const formData = {}
Object.keys(this.data).forEach((key) => {
formData[key] = this.data[key]
})
return formData
}
execute() {
console.log(`Saving data`, this.generateFormData())
}
}
class RefreshAction extends Action {
execute() {
console.log('Refreshing the page')
}
}
Lets say we had an ActionChain
that takes the array of action objects and prepares them for execution:
class ActionChain {
constructor(actions) {
this.actions = actions.reduce((arr, action) => {
switch (action.actionType) {
case 'save':
return arr.concat(new SaveAction(action))
case 'update':
return arr.concat(new UpdateAction(action))
case 'pageJump':
return arr.concat(new PageJumpAction(action))
case 'refresh':
return arr.concat(new RefreshAction(action))
default:
return arr
}
}, [])
}
execute() {
for (let index = 0; index < this.actions.length; index++) {
this.actions[index].execute()
}
}
}
function runActionChain(actionChain) {
actionChain.execute()
}
const actionChain = new ActionChain(actions)
runActionChain(new ActionChain(actions))
console.log('ran all actions')
Each action would run in our app without problems because they all share a compatible interface.
If we wanted to introduce a new action into the app but use a more concise and robust syntax, we have to keep this information in mind if the new implementation breaks your current program when run.
Let's say we're working in the front end for a company and our team manager wants to replace/deprecate pageJump
objects (like { actionType: "pageJump", destination: "/contact" }
) to a shorter syntax like { goto: "/contact" }
. If we make this change in our app then our code would break. This is because our runner runActions
execute's their execute
method which doesn't exist on the new syntax.
We can get around this issue by supporting the goto
action with an Adapter so that the program can behave the same.
We can start by creating a class that extends the base Action
:
class GotoAction extends Action {
constructor(action) {
super(action)
this.url = action.goto
this.history = []
}
setUrl(url) {
this.url = url
}
execute() {
console.log(`Navigating to ${this.url}`)
this.history.push(this.url)
// window.location.href = this.url
}
}
const actionChain = new ActionChain([
new SaveAction({ actionType: 'save', data: 'abc' }),
new GotoAction({ goto: 'https://google.com' }),
])
Or, since goto is basically just another version of pageJump because their end goal is the same, we can define a different adapter that can take in either of the two and return something that can be run in the ActionChain
:
// Adapter for pageJump since we are pretending that goto will replace pageJump
class GotoAdapter extends GotoAction {
constructor(action) {
const gotoActionObject = {
actionType: 'goto',
url: action.destination || action.goto,
}
this.action = gotoActionObject
}
}
This way we can keep our pageJump actions behaving the same. Not only that, we can also make them use the extended methods that the goto has like having access to this.history
and setting new urls whenever we want.
// Incompatible with goto
const pageJumpActionObject = { actionType: 'pageJump', destination: '/faq' }
const pageJump = new PageJumpAction(pageJumpActionObject)
// Compatible
const pageJumpActionObject = { actionType: 'pageJump', destination: '/faq' }
const pageJump = new GotoAdapter(pageJumpActionObject)
pageJump.execute()
console.log(pageJump.history) // ["/faq"]
pageJump.setUrl('https://www.apple.com')
pageJump.execute()
console.log(pageJump.history) // ["/faq", "https:.www.apple.com"]
// Compatible
const gotoActionObject = { goto: '/faq' }
const goto = new GotoAdapter(gotoActionObject) // or --> new GotoAction(gotoActionObject)
goto.execute()
console.log(goto.history) // ["/faq"]
goto.setUrl('https://www.apple.com')
goto.execute()
console.log(goto.history) // ["/faq", "https:.www.apple.com"]
Now from now on we can continue on developing the app further while avoiding breaking changes to migrating some of the old code to new ones:
const someApi = (function () {
class ActionChain {
constructor(actions) {
this.actions = actions.reduce((arr, action) => {
switch (action.actionType) {
case 'save':
return arr.concat(new SaveAction(action))
case 'update':
return arr.concat(new UpdateAction(action))
case 'goto':
return arr.concat(new GotoAction(action))
case 'pageJump':
return arr.concat(new PageJumpAction(action))
case 'refresh':
return arr.concat(new RefreshAction(action))
default:
return arr
}
}, [])
}
execute() {
for (let index = 0; index < this.actions.length; index++) {
this.actions[index].execute()
}
}
}
return {
createActionChain(actionObjects) {
return new ActionChain(actionObjects)
},
}
})()
I believe that more examples in different perspectives helps developers understand concepts a lot more, so here are some more examples that make use of this pattern.
Here is an example taken from a gist:
// old interface
class OldCalculator {
constructor() {
this.operations = function (term1, term2, operation) {
switch (operation) {
case 'add':
return term1 + term2
case 'sub':
return term1 - term2
default:
return NaN
}
}
}
}
// new interface
class NewCalculator {
constructor() {
this.add = function (term1, term2) {
return term1 + term2
}
this.sub = function (term1, term2) {
return term1 - term2
}
}
}
If a calculator app was written using OldCalculator
and wants to provide a way for it to work with the NewCalculator
interface, they need some way to match the NewCalculator
where their behavior runs the same way.
The example below is written using an Adapter where it solves that problem:
// Adapter Class
class CalcAdapter {
constructor() {
const newCalc = new NewCalculator()
this.operations = function (term1, term2, operation) {
switch (operation) {
case 'add':
// using the new implementation under the hood
return newCalc.add(term1, term2)
case 'sub':
return newCalc.sub(term1, term2)
default:
return NaN
}
}
}
}
// usage
const oldCalc = new OldCalculator()
console.log(oldCalc.operations(10, 5, 'add')) // 15
const newCalc = new NewCalculator()
console.log(newCalc.add(10, 5)) // 15
const adaptedCalc = new CalcAdapter()
console.log(adaptedCalc.operations(10, 5, 'add')) // 15;
Earlier in the post I mentioned axios-mock-adapter which uses the Adapter pattern in their code to provide compatibility by supporting the use of promises and the original callback approach:
function adapter() {
return function (config) {
var mockAdapter = this
// axios >= 0.13.0 only passes the config and expects a promise to be
// returned. axios < 0.13.0 passes (config, resolve, reject).
if (arguments.length === 3) {
handleRequest(mockAdapter, arguments[0], arguments[1], arguments[2])
} else {
return new Promise(function (resolve, reject) {
handleRequest(mockAdapter, resolve, reject, config)
})
}
}.bind(this)
}
This is a great example because their syntax are noticeably different, however they both ultimately satisfy their goal in the end. This sets up nicely!
And that concludes the end of this post! I hope you found this to be valuable and look out for more in the future!