We use cookies to enhance your experience on the site
CodeWorlds

Design Patterns in JavaScript/TypeScript - Module, Factory and Observer

NOTE: Extended material - Study plan

This module contains very extensive educational material. You don't have to absorb everything at once! Here is a suggested learning order:

Stage 1 - Fundamentals (priority: high)

  • Module Pattern and Revealing Module Pattern
  • Factory Pattern
  • Observer Pattern
  • Singleton Pattern

Stage 2 - Advanced patterns (priority: medium)

  • Strategy Pattern
  • Decorator Pattern
  • Command Pattern
  • Prototype Pattern

Stage 3 - Architecture and best practices (priority: medium)

  • MVC/MVVM
  • Dependency Injection
  • Repository Pattern

Stage 4 - Practice and integration (study as needed)

  • Reactive Programming (RxJS)
  • State Management Patterns
  • Real-world applications

Tips:

  • Absorb the material sequentially, section by section
  • After each pattern, take a break and practice it on your own
  • Return to more difficult topics after some time
  • Don't skip the fundamentals - they are key to understanding the rest
  • Advanced patterns can be studied as needed in projects

Remember: it's better to understand the basics well than to skim through everything superficially!


Just as ancient architects used proven construction patterns to build durable temples, modern programmers use design patterns to create solid, scalable code. In this lesson, we will learn four fundamental patterns: Module Pattern, Revealing Module Pattern, Factory Pattern, and Observer Pattern, which form the foundation of many modern JavaScript and TypeScript applications.

Module Pattern - Encapsulation and privacy

The Module Pattern is one of the most important patterns in JavaScript, allowing code encapsulation and the creation of private variables and methods. Before the introduction of ES6 modules, it was the main way to organize code into modules.

Classic Module Pattern implementation

1// Example of a simple calculator module
2const Calculator = (function() {
3  // Private variables and functions
4  let result = 0;
5
6  function validateNumber(num) {
7    if (typeof num !== 'number' || isNaN(num)) {
8      throw new Error('The provided value is not a number');
9    }
10  }
11
12  function logOperation(operation, num) {
13    console.log(`Operation performed: ${operation} ${num}`);
14  }
15
16  // Public interface
17  return {
18    add: function(num) {
19      validateNumber(num);
20      result += num;
21      logOperation('addition', num);
22      return this;
23    },
24
25    subtract: function(num) {
26      validateNumber(num);
27      result -= num;
28      logOperation('subtraction', num);
29      return this;
30    },
31
32    multiply: function(num) {
33      validateNumber(num);
34      result *= num;
35      logOperation('multiplication', num);
36      return this;
37    },
38
39    divide: function(num) {
40      validateNumber(num);
41      if (num === 0) {
42        throw new Error('Cannot divide by zero');
43      }
44      result /= num;
45      logOperation('division', num);
46      return this;
47    },
48
49    getResult: function() {
50      return result;
51    },
52
53    reset: function() {
54      result = 0;
55      console.log('Calculator has been reset');
56      return this;
57    }
58  };
59})();
60
61// Usage
62Calculator
63  .add(10)
64  .multiply(2)
65  .subtract(5)
66  .divide(3);
67
68console.log(Calculator.getResult()); // 5

Module Pattern with parameters

1// Module for managing application state
2const StateManager = (function(initialState = {}) {
3  // Private state
4  let state = { ...initialState };
5  let listeners = [];
6
7  // Private helper functions
8  function deepClone(obj) {
9    return JSON.parse(JSON.stringify(obj));
10  }
11
12  function notifyListeners(action, newState) {
13    listeners.forEach(listener => {
14      if (typeof listener === 'function') {
15        listener(action, deepClone(newState));
16      }
17    });
18  }
19
20  // Public interface
21  return {
22    getState: function(key) {
23      if (key) {
24        return deepClone(state[key]);
25      }
26      return deepClone(state);
27    },
28
29    setState: function(key, value) {
30      const oldValue = state[key];
31      state[key] = value;
32
33      notifyListeners({
34        type: 'STATE_CHANGED',
35        key: key,
36        oldValue: oldValue,
37        newValue: value
38      }, state);
39
40      return this;
41    },
42
43    updateState: function(updates) {
44      const oldState = deepClone(state);
45      state = { ...state, ...updates };
46
47      notifyListeners({
48        type: 'STATE_UPDATED',
49        oldState: oldState,
50        newState: deepClone(state)
51      }, state);
52
53      return this;
54    },
55
56    subscribe: function(listener) {
57      if (typeof listener === 'function') {
58        listeners.push(listener);
59      }
60
61      // Return an unsubscribe function
62      return function unsubscribe() {
63        const index = listeners.indexOf(listener);
64        if (index > -1) {
65          listeners.splice(index, 1);
66        }
67      };
68    },
69
70    reset: function() {
71      const oldState = deepClone(state);
72      state = { ...initialState };
73
74      notifyListeners({
75        type: 'STATE_RESET',
76        oldState: oldState
77      }, state);
78
79      return this;
80    }
81  };
82})({ theme: 'light', language: 'pl' });
83
84// Usage
85const unsubscribe = StateManager.subscribe((action, newState) => {
86  console.log('State has changed:', action);
87});
88
89StateManager
90  .setState('user', { name: 'Jan', age: 30 })
91  .updateState({ theme: 'dark', notifications: true });

Revealing Module Pattern - Improved encapsulation

The Revealing Module Pattern is an evolution of the Module Pattern that provides greater readability and better separation between private and public functions.

Basic Revealing Module Pattern implementation

1// Module for managing products in a store
2const ProductManager = (function() {
3  // Private variables
4  let products = [];
5  let nextId = 1;
6
7  // Private functions
8  function generateId() {
9    return nextId++;
10  }
11
12  function validateProduct(product) {
13    if (!product.name || typeof product.name !== 'string') {
14      throw new Error('Product name is required and must be a string');
15    }
16
17    if (!product.price || typeof product.price !== 'number' || product.price <= 0) {
18      throw new Error('Product price must be a number greater than zero');
19    }
20
21    return true;
22  }
23
24  function findProductIndex(id) {
25    return products.findIndex(product => product.id === id);
26  }
27
28  function sortProductsByPrice(ascending = true) {
29    return products.slice().sort((a, b) => {
30      return ascending ? a.price - b.price : b.price - a.price;
31    });
32  }
33
34  function filterProductsByCategory(category) {
35    return products.filter(product =>
36      product.category && product.category.toLowerCase() === category.toLowerCase()
37    );
38  }
39
40  // Public functions
41  function addProduct(productData) {
42    validateProduct(productData);
43
44    const product = {
45      id: generateId(),
46      name: productData.name,
47      price: productData.price,
48      category: productData.category || 'uncategorized',
49      description: productData.description || '',
50      inStock: productData.inStock !== false,
51      createdAt: new Date().toISOString()
52    };
53
54    products.push(product);
55    console.log(`Product added: ${product.name}`);
56
57    return product.id;
58  }
59
60  function removeProduct(id) {
61    const index = findProductIndex(id);
62
63    if (index === -1) {
64      throw new Error(`Product with ID ${id} was not found`);
65    }
66
67    const removedProduct = products.splice(index, 1)[0];
68    console.log(`Product removed: ${removedProduct.name}`);
69
70    return removedProduct;
71  }
72
73  function updateProduct(id, updates) {
74    const index = findProductIndex(id);
75
76    if (index === -1) {
77      throw new Error(`Product with ID ${id} was not found`);
78    }
79
80    const updatedProduct = { ...products[index], ...updates };
81    validateProduct(updatedProduct);
82
83    products[index] = updatedProduct;
84    console.log(`Product updated: ${updatedProduct.name}`);
85
86    return updatedProduct;
87  }
88
89  function getProduct(id) {
90    const product = products.find(p => p.id === id);
91    return product ? { ...product } : null;
92  }
93
94  function getAllProducts() {
95    return products.map(product => ({ ...product }));
96  }
97
98  function getProductsByCategory(category) {
99    return filterProductsByCategory(category).map(product => ({ ...product }));
100  }
101
102  function getProductsSortedByPrice(ascending = true) {
103    return sortProductsByPrice(ascending).map(product => ({ ...product }));
104  }
105
106  function getProductCount() {
107    return products.length;
108  }
109
110  function clearAllProducts() {
111    const count = products.length;
112    products = [];
113    nextId = 1;
114    console.log(`All products removed (${count})`);
115  }
116
117  // Revealing - exposing public methods
118  return {
119    add: addProduct,
120    remove: removeProduct,
121    update: updateProduct,
122    get: getProduct,
123    getAll: getAllProducts,
124    getByCategory: getProductsByCategory,
125    getSortedByPrice: getProductsSortedByPrice,
126    count: getProductCount,
127    clear: clearAllProducts
128  };
129})();
130
131// Usage
132const laptopId = ProductManager.add({
133  name: 'Laptop Dell XPS 13',
134  price: 4500,
135  category: 'Electronics',
136  description: 'High-quality ultrabook laptop'
137});
138
139const phoneId = ProductManager.add({
140  name: 'iPhone 15 Pro',
141  price: 5500,
142  category: 'Electronics'
143});
144
145console.log('Electronics products:', ProductManager.getByCategory('Electronics'));
146console.log('Sorted products:', ProductManager.getSortedByPrice());

TypeScript with Revealing Module Pattern

1// Interfaces for better typing
2interface User {
3  id: number;
4  email: string;
5  name: string;
6  role: 'admin' | 'user' | 'moderator';
7  isActive: boolean;
8  lastLogin?: Date;
9}
10
11interface UserCreateData {
12  email: string;
13  name: string;
14  role?: User['role'];
15}
16
17interface UserUpdateData {
18  name?: string;
19  role?: User['role'];
20  isActive?: boolean;
21}
22
23// User management module in TypeScript
24const UserManager = (function(): {
25  createUser: (userData: UserCreateData) => number;
26  getUserById: (id: number) => User | null;
27  updateUser: (id: number, updates: UserUpdateData) => User | null;
28  deleteUser: (id: number) => boolean;
29  getAllUsers: () => User[];
30  getActiveUsers: () => User[];
31  getUsersByRole: (role: User['role']) => User[];
32  markUserLogin: (id: number) => boolean;
33  getUserCount: () => number;
34} {
35  // Private variables
36  let users: User[] = [];
37  let nextId: number = 1;
38
39  // Private functions
40  function generateId(): number {
41    return nextId++;
42  }
43
44  function validateEmail(email: string): boolean {
45    const emailRegex = /^[^s@]+@[^s@]+.[^s@]+$/;
46    return emailRegex.test(email);
47  }
48
49  function findUserIndex(id: number): number {
50    return users.findIndex(user => user.id === id);
51  }
52
53  function isEmailTaken(email: string, excludeId?: number): boolean {
54    return users.some(user =>
55      user.email.toLowerCase() === email.toLowerCase() &&
56      user.id !== excludeId
57    );
58  }
59
60  function createUserObject(userData: UserCreateData): User {
61    return {
62      id: generateId(),
63      email: userData.email.toLowerCase(),
64      name: userData.name,
65      role: userData.role || 'user',
66      isActive: true,
67      lastLogin: undefined
68    };
69  }
70
71  // Public functions
72  function createUser(userData: UserCreateData): number {
73    if (!validateEmail(userData.email)) {
74      throw new Error('Invalid email format');
75    }
76
77    if (isEmailTaken(userData.email)) {
78      throw new Error('A user with this email already exists');
79    }
80
81    if (!userData.name || userData.name.trim().length < 2) {
82      throw new Error('Username must be at least 2 characters long');
83    }
84
85    const user = createUserObject(userData);
86    users.push(user);
87
88    console.log(`User created: ${user.name} (${user.email})`);
89    return user.id;
90  }
91
92  function getUserById(id: number): User | null {
93    const user = users.find(u => u.id === id);
94    return user ? { ...user } : null;
95  }
96
97  function updateUser(id: number, updates: UserUpdateData): User | null {
98    const index = findUserIndex(id);
99
100    if (index === -1) {
101      return null;
102    }
103
104    const currentUser = users[index];
105    const updatedUser: User = {
106      ...currentUser,
107      ...updates
108    };
109
110    if (updates.name && updates.name.trim().length < 2) {
111      throw new Error('Username must be at least 2 characters long');
112    }
113
114    users[index] = updatedUser;
115    console.log(`User updated: ${updatedUser.name}`);
116
117    return { ...updatedUser };
118  }
119
120  function deleteUser(id: number): boolean {
121    const index = findUserIndex(id);
122
123    if (index === -1) {
124      return false;
125    }
126
127    const deletedUser = users.splice(index, 1)[0];
128    console.log(`User deleted: ${deletedUser.name}`);
129
130    return true;
131  }
132
133  function getAllUsers(): User[] {
134    return users.map(user => ({ ...user }));
135  }
136
137  function getActiveUsers(): User[] {
138    return users.filter(user => user.isActive).map(user => ({ ...user }));
139  }
140
141  function getUsersByRole(role: User['role']): User[] {
142    return users.filter(user => user.role === role).map(user => ({ ...user }));
143  }
144
145  function markUserLogin(id: number): boolean {
146    const index = findUserIndex(id);
147
148    if (index === -1) {
149      return false;
150    }
151
152    users[index].lastLogin = new Date();
153    return true;
154  }
155
156  function getUserCount(): number {
157    return users.length;
158  }
159
160  // Revealing - exposing public methods
161  return {
162    createUser,
163    getUserById,
164    updateUser,
165    deleteUser,
166    getAllUsers,
167    getActiveUsers,
168    getUsersByRole,
169    markUserLogin,
170    getUserCount
171  };
172})();
173
174// Usage with TypeScript
175const adminId = UserManager.createUser({
176  email: 'admin@example.com',
177  name: 'Administrator',
178  role: 'admin'
179});
180
181const userId = UserManager.createUser({
182  email: 'user@example.com',
183  name: 'Jan Kowalski'
184});
185
186console.log('Administrators:', UserManager.getUsersByRole('admin'));
187console.log('Active users:', UserManager.getActiveUsers());

Factory Pattern - Creating objects

The Factory Pattern is a creational pattern that allows creating objects without specifying the concrete class of the object to be created. It is particularly useful when we have different types of objects with a similar structure.

Simple Factory Pattern

1// Factory for creating different types of vehicles
2class VehicleFactory {
3  static createVehicle(type, options = {}) {
4    switch (type.toLowerCase()) {
5      case 'car':
6        return new Car(options);
7      case 'motorcycle':
8        return new Motorcycle(options);
9      case 'truck':
10        return new Truck(options);
11      case 'bicycle':
12        return new Bicycle(options);
13      default:
14        throw new Error(`Unknown vehicle type: ${type}`);
15    }
16  }
17
18  static getSupportedTypes() {
19    return ['car', 'motorcycle', 'truck', 'bicycle'];
20  }
21}
22
23// Base classes
24class Vehicle {
25  constructor(options = {}) {
26    this.brand = options.brand || 'Unknown';
27    this.model = options.model || 'Unknown';
28    this.year = options.year || new Date().getFullYear();
29    this.color = options.color || 'white';
30    this.maxSpeed = options.maxSpeed || 0;
31  }
32
33  start() {
34    console.log(`${this.brand} ${this.model} has been started`);
35  }
36
37  stop() {
38    console.log(`${this.brand} ${this.model} has been stopped`);
39  }
40
41  getInfo() {
42    return `${this.brand} ${this.model} (${this.year}) - ${this.color}`;
43  }
44}
45
46class Car extends Vehicle {
47  constructor(options = {}) {
48    super(options);
49    this.type = 'Car';
50    this.doors = options.doors || 4;
51    this.fuelType = options.fuelType || 'gasoline';
52    this.transmission = options.transmission || 'manual';
53  }
54
55  openTrunk() {
56    console.log('Trunk has been opened');
57  }
58
59  getDetails() {
60    return `${this.getInfo()} - ${this.doors} doors, ${this.fuelType}, ${this.transmission}`;
61  }
62}
63
64class Motorcycle extends Vehicle {
65  constructor(options = {}) {
66    super(options);
67    this.type = 'Motorcycle';
68    this.engineSize = options.engineSize || 125; // cc
69    this.hasSidecar = options.hasSidecar || false;
70  }
71
72  wheelie() {
73    console.log('Doing a wheelie!');
74  }
75
76  getDetails() {
77    const sidecar = this.hasSidecar ? 'with sidecar' : 'without sidecar';
78    return `${this.getInfo()} - ${this.engineSize}cc, ${sidecar}`;
79  }
80}
81
82class Truck extends Vehicle {
83  constructor(options = {}) {
84    super(options);
85    this.type = 'Truck';
86    this.loadCapacity = options.loadCapacity || 1000; // kg
87    this.trailerAttached = options.trailerAttached || false;
88  }
89
90  loadCargo(weight) {
91    if (weight > this.loadCapacity) {
92      console.log(`Cannot load ${weight}kg. Maximum capacity: ${this.loadCapacity}kg`);
93      return false;
94    }
95
96    console.log(`Loaded ${weight}kg cargo`);
97    return true;
98  }
99
100  getDetails() {
101    const trailer = this.trailerAttached ? 'with trailer' : 'without trailer';
102    return `${this.getInfo()} - capacity: ${this.loadCapacity}kg, ${trailer}`;
103  }
104}
105
106class Bicycle extends Vehicle {
107  constructor(options = {}) {
108    super(options);
109    this.type = 'Bicycle';
110    this.gears = options.gears || 1;
111    this.isElectric = options.isElectric || false;
112    this.maxSpeed = options.maxSpeed || 25;
113  }
114
115  ringBell() {
116    console.log('Ring ring! 🔔');
117  }
118
119  getDetails() {
120    const electric = this.isElectric ? 'electric' : 'traditional';
121    return `${this.getInfo()} - ${this.gears} gears, ${electric}`;
122  }
123}
124
125// Using Factory Pattern
126const vehicles = [
127  VehicleFactory.createVehicle('car', {
128    brand: 'Toyota',
129    model: 'Corolla',
130    year: 2023,
131    color: 'blue',
132    doors: 4,
133    fuelType: 'hybrid'
134  }),
135
136  VehicleFactory.createVehicle('motorcycle', {
137    brand: 'Harley-Davidson',
138    model: 'Street 750',
139    color: 'black',
140    engineSize: 750
141  }),
142
143  VehicleFactory.createVehicle('bicycle', {
144    brand: 'Trek',
145    model: 'FX 3',
146    color: 'red',
147    gears: 24,
148    isElectric: false
149  })
150];
151
152vehicles.forEach(vehicle => {
153  console.log(vehicle.getDetails());
154  vehicle.start();
155});

Abstract Factory Pattern

1// Interfaces for Abstract Factory
2interface Button {
3  render(): string;
4  onClick(): void;
5}
6
7interface Input {
8  render(): string;
9  getValue(): string;
10  setValue(value: string): void;
11}
12
13interface Modal {
14  render(): string;
15  show(): void;
16  hide(): void;
17}
18
19// Abstract Factory Interface
20interface UIFactory {
21  createButton(text: string): Button;
22  createInput(placeholder: string): Input;
23  createModal(title: string): Modal;
24}
25
26// Material Design implementations
27class MaterialButton implements Button {
28  constructor(private text: string) {}
29
30  render(): string {
31    return `<button class="material-button">${this.text}</button>`;
32  }
33
34  onClick(): void {
35    console.log(`Material button '${this.text}' clicked`);
36  }
37}
38
39class MaterialInput implements Input {
40  private value: string = '';
41
42  constructor(private placeholder: string) {}
43
44  render(): string {
45    return `<input class="material-input" placeholder="${this.placeholder}" />`;
46  }
47
48  getValue(): string {
49    return this.value;
50  }
51
52  setValue(value: string): void {
53    this.value = value;
54    console.log(`Material input value set to: ${value}`);
55  }
56}
57
58class MaterialModal implements Modal {
59  private isVisible: boolean = false;
60
61  constructor(private title: string) {}
62
63  render(): string {
64    return `
65      <div class="material-modal">
66        <div class="material-modal-header">${this.title}</div>
67        <div class="material-modal-content"></div>
68      </div>
69    `;
70  }
71
72  show(): void {
73    this.isVisible = true;
74    console.log(`Material modal '${this.title}' shown`);
75  }
76
77  hide(): void {
78    this.isVisible = false;
79    console.log(`Material modal '${this.title}' hidden`);
80  }
81}
82
83// Bootstrap implementations
84class BootstrapButton implements Button {
85  constructor(private text: string) {}
86
87  render(): string {
88    return `<button class="btn btn-primary">${this.text}</button>`;
89  }
90
91  onClick(): void {
92    console.log(`Bootstrap button '${this.text}' clicked`);
93  }
94}
95
96class BootstrapInput implements Input {
97  private value: string = '';
98
99  constructor(private placeholder: string) {}
100
101  render(): string {
102    return `<input class="form-control" placeholder="${this.placeholder}" />`;
103  }
104
105  getValue(): string {
106    return this.value;
107  }
108
109  setValue(value: string): void {
110    this.value = value;
111    console.log(`Bootstrap input value set to: ${value}`);
112  }
113}
114
115class BootstrapModal implements Modal {
116  private isVisible: boolean = false;
117
118  constructor(private title: string) {}
119
120  render(): string {
121    return `
122      <div class="modal">
123        <div class="modal-dialog">
124          <div class="modal-content">
125            <div class="modal-header">${this.title}</div>
126            <div class="modal-body"></div>
127          </div>
128        </div>
129      </div>
130    `;
131  }
132
133  show(): void {
134    this.isVisible = true;
135    console.log(`Bootstrap modal '${this.title}' shown`);
136  }
137
138  hide(): void {
139    this.isVisible = false;
140    console.log(`Bootstrap modal '${this.title}' hidden`);
141  }
142}
143
144// Concrete Factories
145class MaterialUIFactory implements UIFactory {
146  createButton(text: string): Button {
147    return new MaterialButton(text);
148  }
149
150  createInput(placeholder: string): Input {
151    return new MaterialInput(placeholder);
152  }
153
154  createModal(title: string): Modal {
155    return new MaterialModal(title);
156  }
157}
158
159class BootstrapUIFactory implements UIFactory {
160  createButton(text: string): Button {
161    return new BootstrapButton(text);
162  }
163
164  createInput(placeholder: string): Input {
165    return new BootstrapInput(placeholder);
166  }
167
168  createModal(title: string): Modal {
169    return new BootstrapModal(title);
170  }
171}
172
173// Client class using Factory
174class Application {
175  private factory: UIFactory;
176
177  constructor(theme: 'material' | 'bootstrap') {
178    this.factory = theme === 'material'
179      ? new MaterialUIFactory()
180      : new BootstrapUIFactory();
181  }
182
183  createLoginForm(): void {
184    const emailInput = this.factory.createInput('Email');
185    const passwordInput = this.factory.createInput('Password');
186    const loginButton = this.factory.createButton('Log in');
187    const modal = this.factory.createModal('Login');
188
189    console.log('Login form:');
190    console.log(emailInput.render());
191    console.log(passwordInput.render());
192    console.log(loginButton.render());
193
194    // Simulating interaction
195    emailInput.setValue('user@example.com');
196    passwordInput.setValue('password123');
197    loginButton.onClick();
198    modal.show();
199  }
200}
201
202// Using Abstract Factory
203const materialApp = new Application('material');
204materialApp.createLoginForm();
205
206console.log('\n---\n');
207
208const bootstrapApp = new Application('bootstrap');
209bootstrapApp.createLoginForm();

Observer Pattern - Reactive programming

The Observer Pattern is a behavioral pattern that defines a one-to-many dependency between objects. When the state of one object changes, all dependent objects are automatically notified and updated.

Basic Observer Pattern implementation

1// Subject class (Observable)
2class Subject {
3  constructor() {
4    this.observers = [];
5  }
6
7  subscribe(observer) {
8    if (typeof observer.update === 'function') {
9      this.observers.push(observer);
10      console.log('Observer has been added');
11    } else {
12      throw new Error('Observer must implement the update() method');
13    }
14  }
15
16  unsubscribe(observer) {
17    const index = this.observers.indexOf(observer);
18    if (index > -1) {
19      this.observers.splice(index, 1);
20      console.log('Observer has been removed');
21    }
22  }
23
24  notify(data) {
25    console.log(`Notifying ${this.observers.length} observers`);
26    this.observers.forEach(observer => {
27      observer.update(data);
28    });
29  }
30}
31
32// Concrete Subject - Stock market monitor
33class StockPrice extends Subject {
34  constructor(symbol, price) {
35    super();
36    this.symbol = symbol;
37    this.price = price;
38    this.history = [{ price, timestamp: new Date() }];
39  }
40
41  setPrice(newPrice) {
42    const oldPrice = this.price;
43    this.price = newPrice;
44
45    const change = newPrice - oldPrice;
46    const changePercent = ((change / oldPrice) * 100).toFixed(2);
47
48    const priceData = {
49      symbol: this.symbol,
50      oldPrice,
51      newPrice,
52      change,
53      changePercent,
54      timestamp: new Date()
55    };
56
57    this.history.push({
58      price: newPrice,
59      timestamp: priceData.timestamp
60    });
61
62    this.notify(priceData);
63  }
64
65  getPrice() {
66    return this.price;
67  }
68
69  getHistory() {
70    return [...this.history];
71  }
72}
73
74// Observers (subscribers)
75class PriceDisplay {
76  constructor(name) {
77    this.name = name;
78  }
79
80  update(priceData) {
81    const direction = priceData.change >= 0 ? '📈' : '📉';
82    const changeText = priceData.change >= 0 ? '+' : '';
83
84    console.log(`[${this.name}] ${priceData.symbol}: ${priceData.newPrice} USD ` +
85               `(${changeText}${priceData.change.toFixed(2)} / ${changeText}${priceData.changePercent}%) ${direction}`);
86  }
87}
88
89class PriceAlert {
90  constructor(thresholdPrice, type = 'above') {
91    this.thresholdPrice = thresholdPrice;
92    this.type = type; // 'above' or 'below'
93    this.alerted = false;
94  }
95
96  update(priceData) {
97    const shouldAlert = this.type === 'above'
98      ? priceData.newPrice >= this.thresholdPrice
99      : priceData.newPrice <= this.thresholdPrice;
100
101    if (shouldAlert && !this.alerted) {
102      console.log(`🚨 ALERT: ${priceData.symbol} ${this.type === 'above' ? 'exceeded' : 'dropped below'} ` +
103                 `${this.thresholdPrice} USD (current price: ${priceData.newPrice} USD)`);
104      this.alerted = true;
105    } else if (!shouldAlert) {
106      this.alerted = false;
107    }
108  }
109}
110
111class PortfolioTracker {
112  constructor(shares) {
113    this.shares = shares;
114    this.initialValue = null;
115  }
116
117  update(priceData) {
118    const currentValue = priceData.newPrice * this.shares;
119
120    if (this.initialValue === null) {
121      this.initialValue = priceData.oldPrice * this.shares;
122    }
123
124    const profit = currentValue - this.initialValue;
125    const profitPercent = ((profit / this.initialValue) * 100).toFixed(2);
126
127    console.log(`📊 Portfolio: ${this.shares} shares of ${priceData.symbol} = ${currentValue.toFixed(2)} USD ` +
128               `(profit/loss: ${profit.toFixed(2)} USD / ${profitPercent}%)`);
129  }
130}
131
132// Using Observer Pattern
133const teslaStock = new StockPrice('TSLA', 250.00);
134
135// Creating observers
136const mainDisplay = new PriceDisplay('Main screen');
137const mobileApp = new PriceDisplay('Mobile app');
138const highPriceAlert = new PriceAlert(260, 'above');
139const lowPriceAlert = new PriceAlert(240, 'below');
140const myPortfolio = new PortfolioTracker(10);
141
142// Subscribing observers
143teslaStock.subscribe(mainDisplay);
144teslaStock.subscribe(mobileApp);
145teslaStock.subscribe(highPriceAlert);
146teslaStock.subscribe(lowPriceAlert);
147teslaStock.subscribe(myPortfolio);
148
149// Simulating price changes
150console.log('=== TSLA Stock Price Change Simulation ===\n');
151
152teslaStock.setPrice(255.75);
153console.log('');
154
155teslaStock.setPrice(262.30);
156console.log('');
157
158teslaStock.setPrice(245.20);
159console.log('');
160
161teslaStock.setPrice(235.80);

Observer Pattern with Event Emitter

1// TypeScript implementation with Event Emitter
2interface EventListener<T = any> {
3  (data: T): void;
4}
5
6class EventEmitter<T extends Record<string, any> = Record<string, any>> {
7  private listeners: Map<keyof T, Set<EventListener<T[keyof T]>>> = new Map();
8
9  on<K extends keyof T>(event: K, listener: EventListener<T[K]>): () => void {
10    if (!this.listeners.has(event)) {
11      this.listeners.set(event, new Set());
12    }
13
14    this.listeners.get(event)!.add(listener);
15
16    // Return an unsubscribe function
17    return () => this.off(event, listener);
18  }
19
20  off<K extends keyof T>(event: K, listener: EventListener<T[K]>): void {
21    const eventListeners = this.listeners.get(event);
22    if (eventListeners) {
23      eventListeners.delete(listener);
24      if (eventListeners.size === 0) {
25        this.listeners.delete(event);
26      }
27    }
28  }
29
30  emit<K extends keyof T>(event: K, data: T[K]): void {
31    const eventListeners = this.listeners.get(event);
32    if (eventListeners) {
33      eventListeners.forEach(listener => {
34        try {
35          listener(data);
36        } catch (error) {
37          console.error(`Error in event listener for ${String(event)}:`, error);
38        }
39      });
40    }
41  }
42
43  once<K extends keyof T>(event: K, listener: EventListener<T[K]>): void {
44    const onceListener = (data: T[K]) => {
45      listener(data);
46      this.off(event, onceListener);
47    };
48
49    this.on(event, onceListener);
50  }
51
52  removeAllListeners<K extends keyof T>(event?: K): void {
53    if (event) {
54      this.listeners.delete(event);
55    } else {
56      this.listeners.clear();
57    }
58  }
59
60  listenerCount<K extends keyof T>(event: K): number {
61    const eventListeners = this.listeners.get(event);
62    return eventListeners ? eventListeners.size : 0;
63  }
64}
65
66// Event type definitions
67interface UserEvents {
68  login: { userId: string; email: string; timestamp: Date };
69  logout: { userId: string; timestamp: Date };
70  profileUpdate: { userId: string; changes: Record<string, any> };
71  passwordChange: { userId: string; timestamp: Date };
72}
73
74// Usage example with TypeScript
75class UserService extends EventEmitter<UserEvents> {
76  private users: Map<string, any> = new Map();
77
78  login(email: string, password: string): boolean {
79    // Simulating login
80    const userId = 'user_' + Math.random().toString(36).substr(2, 9);
81
82    this.users.set(userId, {
83      id: userId,
84      email,
85      lastLogin: new Date()
86    });
87
88    this.emit('login', {
89      userId,
90      email,
91      timestamp: new Date()
92    });
93
94    return true;
95  }
96
97  logout(userId: string): void {
98    if (this.users.has(userId)) {
99      this.emit('logout', {
100        userId,
101        timestamp: new Date()
102      });
103
104      this.users.delete(userId);
105    }
106  }
107
108  updateProfile(userId: string, changes: Record<string, any>): void {
109    if (this.users.has(userId)) {
110      const user = this.users.get(userId);
111      const updatedUser = { ...user, ...changes };
112      this.users.set(userId, updatedUser);
113
114      this.emit('profileUpdate', {
115        userId,
116        changes
117      });
118    }
119  }
120
121  changePassword(userId: string, newPassword: string): void {
122    if (this.users.has(userId)) {
123      // Simulating password change
124      this.emit('passwordChange', {
125        userId,
126        timestamp: new Date()
127      });
128    }
129  }
130}
131
132// Usage
133const userService = new UserService();
134
135// Listening for events
136const unsubscribeLogin = userService.on('login', (data) => {
137  console.log(`User ${data.email} logged in at ${data.timestamp.toLocaleTimeString()}`);
138});
139
140userService.on('logout', (data) => {
141  console.log(`User ${data.userId} logged out at ${data.timestamp.toLocaleTimeString()}`);
142});
143
144userService.on('profileUpdate', (data) => {
145  console.log(`User ${data.userId} updated their profile:`, data.changes);
146});
147
148userService.once('passwordChange', (data) => {
149  console.log(`🔒 Password has been changed for user ${data.userId}`);
150});
151
152// Testing
153console.log('=== Test User Service Events ===\n');
154
155const success = userService.login('jan@example.com', 'password123');
156if (success) {
157  const userId = Array.from(userService['users'].keys())[0];
158
159  setTimeout(() => {
160    userService.updateProfile(userId, {
161      name: 'Jan Kowalski',
162      age: 30
163    });
164  }, 1000);
165
166  setTimeout(() => {
167    userService.changePassword(userId, 'newPassword456');
168  }, 2000);
169
170  setTimeout(() => {
171    userService.logout(userId);
172  }, 3000);
173}

Comparing patterns and best practices

When to use which pattern?

  1. Module Pattern / Revealing Module Pattern:

    • ✅ When you need encapsulation and privacy
    • ✅ For creating singletons
    • ✅ For organizing code into namespaces
    • ❌ When you need multiple instances
    • ❌ For inheritance
  2. Factory Pattern:

    • ✅ When the object type is determined at runtime
    • ✅ For unifying the object creation process
    • ✅ When you have many similar classes
    • ❌ When you have only one class to create
    • ❌ For simple objects
  3. Observer Pattern:

    • ✅ For reactive programming
    • ✅ When changes in one object affect many others
    • ✅ For implementing event-driven architecture
    • ❌ When you have simple, direct relationships
    • ❌ For synchronous operations

Advantages and disadvantages

1// Example of combining patterns in a practical application
2interface NotificationData {
3  message: string;
4  type: 'info' | 'warning' | 'error' | 'success';
5  timestamp: Date;
6}
7
8// Singleton with Module Pattern + Observer Pattern
9const NotificationSystem = (function() {
10  // Private variables
11  let instance: NotificationSystem | null = null;
12  let notifications: NotificationData[] = [];
13  let observers: Array<(notification: NotificationData) => void> = [];
14
15  class NotificationSystem {
16    // Factory Method Pattern
17    static createNotification(message: string, type: NotificationData['type']): NotificationData {
18      return {
19        message,
20        type,
21        timestamp: new Date()
22      };
23    }
24
25    addNotification(message: string, type: NotificationData['type']): void {
26      const notification = NotificationSystem.createNotification(message, type);
27      notifications.push(notification);
28      this.notifyObservers(notification);
29    }
30
31    // Observer Pattern methods
32    subscribe(callback: (notification: NotificationData) => void): () => void {
33      observers.push(callback);
34
35      return () => {
36        const index = observers.indexOf(callback);
37        if (index > -1) {
38          observers.splice(index, 1);
39        }
40      };
41    }
42
43    private notifyObservers(notification: NotificationData): void {
44      observers.forEach(observer => observer(notification));
45    }
46
47    getNotifications(): NotificationData[] {
48      return [...notifications];
49    }
50
51    clearNotifications(): void {
52      notifications = [];
53    }
54  }
55
56  // Singleton implementation
57  return {
58    getInstance(): NotificationSystem {
59      if (!instance) {
60        instance = new NotificationSystem();
61      }
62      return instance;
63    }
64  };
65})();
66
67// Using combined patterns
68const notificationService = NotificationSystem.getInstance();
69
70// Subscribers (Observer Pattern)
71const unsubscribe1 = notificationService.subscribe((notification) => {
72  console.log(`[UI] ${notification.type.toUpperCase()}: ${notification.message}`);
73});
74
75const unsubscribe2 = notificationService.subscribe((notification) => {
76  if (notification.type === 'error') {
77    console.log(`[LOG] Error logged: ${notification.message} at ${notification.timestamp}`);
78  }
79});
80
81// Testing
82notificationService.addNotification('Welcome to the application!', 'info');
83notificationService.addNotification('Warning: Low disk space', 'warning');
84notificationService.addNotification('Server connection error', 'error');
85notificationService.addNotification('Data saved successfully', 'success');

Summary

The Module, Factory, and Observer design patterns are the foundations of modern JavaScript/TypeScript programming:

  • Module Pattern provides encapsulation and code organization
  • Revealing Module Pattern improves readability through clear public API definition
  • Factory Pattern abstracts the object creation process
  • Observer Pattern enables reactive programming and loose coupling

Just as ancient builders used proven architectural patterns, modern programmers use these design patterns to create solid, scalable applications. Knowledge of these patterns is essential for every JavaScript/TypeScript programmer.

Go to CodeWorlds