Adapter Pattern in JavaScript

Christopher T.

October 29th, 2020

Share This Post

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.

How It Looks Like

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)
    },
  }
})()

Other examples

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.

Calculator

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;

Axios adapter

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!

Conclusion

And that concludes the end of this post! I hope you found this to be valuable and look out for more in the future!


Tags


Subscribe to the Newsletter
Get continuous updates
© jsmanifest 2020