Lucdev Website
Observer pattern illustration

Implementing the Observer pattern using JavaScript & TypeScript

Sep 3, 2024 - 11 minutes read.

🔧 You can access the source code for everything in this article in this repository.

This article takes a traditional object-oriented approach. While these abstractions may not be necessary for all projects, they provide a solid understanding of the Observer pattern. You can choose simpler alternatives like functions and plain objects if they suit your needs better.

Note: Some symbol names in this implementation differ from the traditional Observer pattern to avoid conflicts with JavaScript’s predefined symbols.

What is the Observer pattern?

The Observer pattern is a design pattern that decouples the subject of an event from the observers who want to react to it. The subject doesn’t need to know any implementation details about the observers, only that it should notify them when an event occurs.

📩 Example: Imagine a mailing list. You can subscribe to receive notifications whenever a new message is posted. You don’t need to know how the mailing list works; you just receive the updates.

Another example is a chat application 💬, where you can subscribe to a chat room to get notified whenever a new message is posted.

As you can see, the Observer pattern is highly useful and widely applicable in real-world scenarios.

Writing the implementation

In our vanilla JavaScript implementation, we’ll utilize ES2015+ features, such as classes.

If you’d like to explore the TypeScript implementation, simply click the TypeScript tab in any of the code snippets.

Prelude: Vanilla JS Gotchas & Workarounds

✅ If you just want to see the TypeScript implementation, feel free to skip ahead this section.

Before we start, let me introduce some practices I used to work-around some missing features in JavaScript and to enchance the overall developer experience.

First step: Abstract classes

First, we need to define our contracts or interfaces 📜. We’ll create our own abstract classes.

JavaScript
/**
 * @abstact
 */
class MyAbstractClass {
  constructor() {
    if (new.target === MyAbstractClass) {
      throw new TypeError(
        "Cannot construct MyAbstractClass instances directly"
      );
    }
  }
}

This runtime check makes sure that we can’t instantiate the abstract class directly.

To create a concrete class, we simply extend the abstract class.

JavaScript
class MyConcreteClass extends MyAbstractClass {
  constructor() {
    super();
  }
}

Second step: Typing & Editor autocompletion

To workaround the lack of types in JS, we will use JSDoc comments to get better autocompletion in IDEs.

Here for example we will define a simple User class, making use of JavaScript private properties and getters.

JavaScript
class User {
  /**
   * @private
   * @type {string}
   */
  #firstName;
  /**
   * @private
   * @type {string}
   */
  #lastName;
  
  /**
   * @param {string} firstName
   * @param {string} lastName
   */
  constructor(firstName, lastName) {
    this.#firstName = firstName;
    this.#lastName = lastName;
  }

  get firstName() {
    return this.#firstName;
  }

  get lastName() {
    return this.#lastName;
  }

  get fullName() {
    return `${this.#firstName} ${this.#lastName}`;
  }
}

const user = new User("Ada", "Lovelace");
console.log(user.fullName);

If you hover over the fullName property, your editor should tell you that it is of type string 💪🏻

Observer Pattern contract: Using abstract classes

👏🏻 Now that we settled everything, we can start implementing the Observer pattern.

With these interfaces and abstract classes, we can now define the behaviors that any class implementing the Observer pattern must follow.

Think of it as a contract that outlines how all future implementations should behave, in the most general terms possible.

AbstractMessage

We’ll begin with the AbstractMessage class, which acts as a contract for communication between the subject and observers. It essentially wraps the data that will be passed to the observers.

JavaScript
/**
 * @abstract
 */
class AbstractMessage {
  constructor() {
    if (new.target === AbstractMessage) {
      throw new TypeError(
        "Cannot construct AbstractMessage instances directly"
      );
    }
  }

  /**
   * @abstract
   * @returns {Record<string, unknown>}
   */
  get payload() {
    throw new Error("Not implemented");
  }
}

The payload property is suggested to return a Record<string, unknown> because it is a common type for all messages, if you feel that you might need any kind of result type, I suggest you use the unknown type.

💡 If you are unfamiliar with the Record type, it allows you to define key-value pairs where the key is a string and the value can be of any type, thanks to the unknown type.

For the TypeScript implementation, we’ll use a generic type to define the payload type.

AbstractListener

This contract represents the observers aka any class that wants to listen to the subject’s events.

JavaScript
/**
 * @abstract
 */
class AbstractListener {
  constructor() {
    if (new.target === AbstractListener) {
      throw new TypeError(
        "Cannot construct AbstractListener instances directly",
      );
    }
  }

  /**
   * @abstract
   * @params {AbstractMessage} message
   * @returns {Promise<void>}
   */
  async update(message) {
    throw new Error("Not implemented");
  }

  /**
   * @abstract
   * @returns {string}
   */
  get id() {
    throw new Error("Not implemented");
  }
}

🔑 The key takeaway is that listeners receive messages and must have a unique identifier.

AbstractPublisher

This class represents a Publisher, which is the entity responsible for pushing messages to its audience.

JavaScript
/**
 * @abstract
 */
class AbstractPublisher {
  constructor() {
    if (new.target === AbstractPublisher) {
      throw new TypeError(
        "Cannot construct AbstractPublisher instances directly",
      );
    }
  }

  /**
   * @abstract
   * @param {AbstractListener} listener
   * @returns {Promise<void>}
   */
  async addListener(listener) {
    if (!(listener instanceof AbstractListener)) {
      throw TypeError("listener must be an AbstractListener");
    }
    throw new Error("Not implemented");
  }

  /**
   * @abstract
   * @param {AbstractListener} listener
   * @returns {Promise<void>}
   */
  async removeListener(listener) {
    if (!(listener instanceof AbstractListener)) {
      throw TypeError("listener must be an AbstractListener");
    }
    throw new Error("Not implemented");
  }

  /**
   * @abstract
   * @param {AbstractMessage} message
   * @returns {Promise<void>}
   */
  async notifyListeners(message) {
    if (!(message instanceof AbstractMessage)) {
      throw TypeError("message must be an AbstractMessage");
    }
    throw new Error("Not implemented");
  }
}

This class includes three key behaviors:

Observer Pattern implementation: Concrete classes

Publisher

The Publisher class is responsible for sending messages to its audience.

JavaScript
class Publisher extends AbstractPublisher {
  /**
   * @type {Set<AbstractListener>}
   */
  #listeners = new Set();

  /**
   * @param {AbstractListener} listener
   * @returns {Promise<void>}
   */
  async addListener(listener) {
    console.log("addListener", listener.id);
    this.#listeners.add(listener);
  }

  /**
   * @param {AbstractListener} listener
   * @returns {Promise<void>}
   */
  async removeListener(listener) {
    console.log("removeListener", listener.id);
    for (const l of this.#listeners) {
      if (l.id === listener.id) {
        this.#listeners.delete(l);
        break;
      }
    }
    throw new Error("Listener not found");
  }

  /**
   * @param {AbstractMessage} message
   * @returns {Promise<void>}
   */
  async notifyListeners(message) {
    for (const l of this.#listeners) {
      await l.update(message);
    }
  }
}

This simple implementation uses a Set to store listeners, and it identifies them using their id property.

Listener

The Listener class is the entity that listens for updates from the subject. Here is our concrete implementation:

JavaScript
class Listener extends AbstractListener {
  /**
   * @type {string}
   */
  #id;

  /**
   * @param {string} id
   */
  constructor(id) {
    super();
    this.#id = id;
  }

  /**
   * @returns {string}
   * @override
   */
  get id() {
    return this.#id;
  }

  /**
   * @param {AbstractMessage} message
   * @returns {Promise<void>}
   * @override
   */
  async update(message) {
    console.log("update", message.payload);
    console.log("to listener", this.#id);
  }
}

Example: Implementing Message & Mailing list

With our implementation complete, here’s an example that demonstrates how to instantiate and use these classes.

JavaScript
class Message extends AbstractMessage {
  /**
   * @type {string}
   */
  #subject;
  /**
   * @type {string}
   */
  #content;

  /**
   * @param {string} subject
   * @param {string} content
   */
  constructor(subject, content) {
    super();
    this.#subject = subject;
    this.#content = content;
  }

  get payload() {
    return {
      subject: this.#subject,
      content: this.#content,
    };
  }
}

With now all our implementation is done!

Here is the sample code that instanciates the classes and uses them.

JavaScript
const publisher = new Publisher();

const listenerA = new Listener("Alice");
const listenerB = new Listener("Bob");

publisher.addListener(listenerA);
publisher.addListener(listenerB);

const message = new Message(
  "Mailing list update",
  "Welcome to all members!\nThis is the mailing list.\nPlease try our new product, you can find it at https://example.com.\nGreetings!",
);

publisher.notifyListeners(message);

Annex: UML Diagram & flowchart

UML Diagram

Flowchart

Conclusion

I hope this article has been helpful in explaining the Observer pattern and its implementation. Good luck with your project!

👋 Be well.

Tags: