🔧 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.
/**
* @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.
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.
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.
/**
* @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");
}
}
abstract class AbstractMessage<T> {
constructor() {
if (new.target === AbstractMessage) {
throw new TypeError(
"Cannot construct AbstractMessage instances directly",
);
}
}
abstract get payload(): T;
}
/**
* @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");
}
}
/**
* @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");
}
}
abstract class AbstractMessage<T> {
constructor() {
if (new.target === AbstractMessage) {
throw new TypeError(
"Cannot construct AbstractMessage instances directly",
);
}
}
abstract get payload(): T;
}
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 theunknown
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.
/**
* @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");
}
}
abstract class AbstractListener<T> {
constructor() {
if (new.target === AbstractListener) {
throw new TypeError(
"Cannot construct AbstractListener instances directly",
);
}
}
abstract update(message: AbstractMessage<T>): Promise<void>;
abstract get id(): string;
}
/**
* @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");
}
}
/**
* @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");
}
}
abstract class AbstractListener<T> {
constructor() {
if (new.target === AbstractListener) {
throw new TypeError(
"Cannot construct AbstractListener instances directly",
);
}
}
abstract update(message: AbstractMessage<T>): Promise<void>;
abstract get id(): string;
}
🔑 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.
/**
* @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");
}
}
abstract class AbstractPublisher<T> {
constructor() {
if (new.target === AbstractPublisher) {
throw new TypeError(
"Cannot construct AbstractPublisher instances directly",
);
}
}
abstract addListener(listener: AbstractListener<T>): Promise<void>;
abstract removeListener(listener: AbstractListener<T>): Promise<void>;
abstract notifyListeners(message: AbstractMessage<T>): Promise<void>;
}
/**
* @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");
}
}
/**
* @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");
}
}
abstract class AbstractPublisher<T> {
constructor() {
if (new.target === AbstractPublisher) {
throw new TypeError(
"Cannot construct AbstractPublisher instances directly",
);
}
}
abstract addListener(listener: AbstractListener<T>): Promise<void>;
abstract removeListener(listener: AbstractListener<T>): Promise<void>;
abstract notifyListeners(message: AbstractMessage<T>): Promise<void>;
}
This class includes three key behaviors:
- Adding a listener to the audience.
- Removing a listener from the audience.
- Notifying all listeners with a specific message.
Observer Pattern implementation: Concrete classes
Publisher
The Publisher
class is responsible for sending messages to its audience.
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);
}
}
}
class Publisher<T> extends AbstractPublisher<T> {
private listeners: Set<AbstractListener<T>> = new Set();
async addListener(listener: AbstractListener<T>): Promise<void> {
console.log("addListener", listener.id);
this.listeners.add(listener);
}
async removeListener(listener: AbstractListener<T>): Promise<void> {
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");
}
async notifyListeners(message: AbstractMessage<T>): Promise<void> {
for (const listener of this.listeners) {
await listener.update(message);
}
}
}
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);
}
}
}
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);
}
}
}
class Publisher<T> extends AbstractPublisher<T> {
private listeners: Set<AbstractListener<T>> = new Set();
async addListener(listener: AbstractListener<T>): Promise<void> {
console.log("addListener", listener.id);
this.listeners.add(listener);
}
async removeListener(listener: AbstractListener<T>): Promise<void> {
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");
}
async notifyListeners(message: AbstractMessage<T>): Promise<void> {
for (const listener of this.listeners) {
await listener.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:
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);
}
}
class Listener<T> extends AbstractListener<T> {
private readonly _id: string;
constructor(id: string) {
super();
this._id = id;
}
get id(): string {
return this._id;
}
async update(message: AbstractMessage<T>): Promise<void> {
console.log("update", message.payload);
console.log("to listener", this._id);
}
}
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);
}
}
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);
}
}
class Listener<T> extends AbstractListener<T> {
private readonly _id: string;
constructor(id: string) {
super();
this._id = id;
}
get id(): string {
return this._id;
}
async update(message: AbstractMessage<T>): Promise<void> {
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.
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,
};
}
}
type MessagePayload = { subject: string; content: string };
class Message extends AbstractMessage<MessagePayload> {
private readonly _payload: MessagePayload;
constructor(payload: MessagePayload) {
super();
this._payload = payload;
}
get payload(): MessagePayload {
return this._payload;
}
}
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,
};
}
}
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,
};
}
}
type MessagePayload = { subject: string; content: string };
class Message extends AbstractMessage<MessagePayload> {
private readonly _payload: MessagePayload;
constructor(payload: MessagePayload) {
super();
this._payload = payload;
}
get payload(): MessagePayload {
return this._payload;
}
}
With now all our implementation is done!
Here is the sample code that instanciates the classes and uses them.
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);
const publisher = new Publisher<MessagePayload>();
const listenerA = new Listener("Alice");
const listenerB = new Listener("Bob");
publisher.addListener(listenerA);
publisher.addListener(listenerB);
const message = new Message({
subject: "Mailing list update",
content:
"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);
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);
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);
const publisher = new Publisher<MessagePayload>();
const listenerA = new Listener("Alice");
const listenerB = new Listener("Bob");
publisher.addListener(listenerA);
publisher.addListener(listenerB);
const message = new Message({
subject: "Mailing list update",
content:
"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
Conclusion
I hope this article has been helpful in explaining the Observer pattern and its implementation. Good luck with your project!
👋🏻 Be well.