Created At 2025-02-27 • #dev

Dive into JavaScript Decorators: Step by Step to Method Enhancements

This came from one of my technical sharing. At at time we’re reading the Head First Design Patterns book.

An Example: the Notifier class

Consider a social media platform that alerts users about new messages and friend requests. We’re going to create a Notifier class to handle these notifications effectively.

class EmailNotifier {
  nofify() {
    console.log("We will send an Email to the user.")
  }
}

Meanwhile, users can subscribe to notifications on other platforms, such as Microsoft Teams. Subclassing is the most straightforward approach to extending a method and achieving our goal.

class EmailNotifier { /* */ }

class TeamsEmailNotifier extends EmailNotifier {
  nofify() {
    super.nofify()
    console.log("We will send a notification on Teams")
  }
}

Things become more complicated when the user wants to receive notifications from Jira at the same time.

class EmailNotifier { /* */ }

class TeamsEmailNotifier extends EmailNotifier { /* */ }

class JiraTeamsEmailNotifier extends TeamsEmailNotifier {
  nofify() {
    super.nofify()
    console.log("We will send a notification on Jira")
  }
}

The challenge now lies in maintaining these classes to support a variety of platforms. Additionally, users must be able to cancel any subscription.

For instance, should we create a separate class to handle users who receive notifications from Jira and Teams, but not email?

class EmailNotifier { /* */ }

class TeamsEmailNotifier extends EmailNotifier { /* */ }

class JiraTeamsEmailNotifier extends TeamsEmailNotifier { /* */ }

class TeamsNotifier { /* */ }

class JiraTeamsNotifier extends TeamsNotifier { /* */ }

The number of required classes increases exponentially with each supported platform. Implementing notifications for 6 platforms would necessitate 64 classes, which is clearly unsustainable.

Has-One rather than Is-One

Let’s take a break and reconsider our code. It doesn’t make sense to regard the Teams Notifier as a special type of Email Notifier. Indeed these classes should have a parallel relationship rather than a belong-to relationship.

Let’s refactor the code as follows:

interface Notifier {
  notify(): void
}

class EmailNotifier implements Notifier {
  constructor(private notifier?: Notifier){}
  notify() {
    this.notifier?.notify()
    console.log("We will send an Email to the user.")
  }
}

class TeamsNotifier implements Notifier {
  constructor(private notifier?: Notifier){}
  notify() {
    this.notifier?.notify()
    console.log("We will send a notification on Teams")
  }
}

class JiraNotifier implements Notifier {
  constructor(private notifier?: Notifier){}
  notify() {
    this.notifier?.notify()
    console.log("We will send a notification on Jira")
  }
}

Now we can compose these classes arbitrarily:

new JiraNotifier(new TeamsNotifier()).notify()

new EmailNotifier(new JiraNotifier()).notify()

Wrapping instead of Holding

Our code is getting more flexible and maintainable. However, there are still some deficiencies in the snippets.

The Notifiers don’t need to know each other. What’s more, do you remember that a class is actually syntactic sugar in JavaScript? We can implement the notification services in a more JavaScript-style way.

We will implement a function that receives a class and returns it with its methods wrapped.

function makeJiraNotifier($class) { /* TODO */ }

class _Notifier {
	notify() {}
}

export const JiraNotifier = makeJiraNotifier(_Notifier)

In the makeJiraNotifier function, we need to get the original method first.

function makeJiraNotifier($class) {
  const { notify } = $class.prototype
}

And then redefine this property as a new function.

function makeJiraNotifier($class) {
  const { notify } = $class.prototype
  Object.defineProperty($class.prototype, 'notify', {
    value(...args) {

    }
  })
}

The new function first calls the original function. Then, it executes the enhancement logic we want to implement.

function makeJiraNotifier($class) {
  const { notify } = $class.prototype
  Object.defineProperty($class.prototype, 'notify', {
    value(...args) {
      notify.apply(this, args)
      console.log('Send message by Jira')
    }
  })
}

Finally, we can implement other wrappers in a similar manner.

function makeJiraNotifier($class) {
  const { notify } = $class.prototype
  Object.defineProperty($class.prototype, 'notify', {
    value(...args) {
      notify.apply(this, args)
      console.log('Send message by Jira')
    }
  })
}

class _Notifier {
	notify() {}
}

export const JiraNotifier = makeJiraNotifier(_Notifier)

Decorator Syntax in TypeScript

TypeScript provides a more elegant approach to maintaining your decorators.

function jira(target, propertyKey, descriptor) {
  return {
    value(...args) {
      descriptor.value.apply(this, args)
      console.log('jira')
    }
  }
}


class N {
  @jira
  notify() {

  }
}
function jira<This, Args extends any[], Return>(
    target: (this: This, ...arg: Args) => Return,
    context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
    return function (this: This, ...args: Args) {
        target.apply(this, args)
        console.log('notify jira')
    }
}

class N {
    @jira
    notify() {

    }
}

A Real-World App with Decorators

Reference