Introduction
Beyond basic class syntax, several patterns help write cleaner, more maintainable object-oriented JavaScript. From handling this in callbacks to implementing common design patterns, these techniques are essential for production code.
Key Concepts
Auto-binding: Ensuring methods have correct this when used as callbacks.
Factory Methods: Static methods that create instances with custom logic.
Immutable Classes: Classes that don't allow modification after creation.
Real World Context
React class components need bound methods. Data models often use factories. Service classes implement singleton patterns. These patterns appear throughout modern JavaScript codebases.
Deep Dive
Binding Methods for Callbacks
javascriptclass Counter { count = 0; // Problem: 'this' is lost in callbacks increment() { this.count++; } // Solution 1: Arrow function property (most common) decrement = () => { this.count--; }; } const counter = new Counter(); // increment loses 'this' setTimeout(counter.increment, 100); // Error! // decrement works (arrow function) setTimeout(counter.decrement, 100); // Works! // Solution 2: Bind in constructor class Counter2 { constructor() { this.increment = this.increment.bind(this); } increment() { this.count++; } }
Factory Pattern
javascriptclass User { constructor(data) { this.name = data.name; this.email = data.email; this.role = data.role; } // Factory methods static createAdmin(name, email) { return new User({ name, email, role: 'admin' }); } static createGuest() { return new User({ name: 'Guest', email: '', role: 'guest' }); } static async fromAPI(id) { const response = await fetch(`/api/users/${id}`); const data = await response.json(); return new User(data); } } const admin = User.createAdmin('Alice', 'alice@admin.com'); const guest = User.createGuest(); const user = await User.fromAPI(123);
Builder Pattern
javascriptclass QueryBuilder { #table; #conditions = []; #orderBy = null; #limit = null; from(table) { this.#table = table; return this; // Enable chaining } where(condition) { this.#conditions.push(condition); return this; } order(field, direction = 'ASC') { this.#orderBy = `${field} ${direction}`; return this; } take(n) { this.#limit = n; return this; } build() { let sql = `SELECT * FROM ${this.#table}`; if (this.#conditions.length) { sql += ` WHERE ${this.#conditions.join(' AND ')}`; } if (this.#orderBy) sql += ` ORDER BY ${this.#orderBy}`; if (this.#limit) sql += ` LIMIT ${this.#limit}`; return sql; } } const query = new QueryBuilder() .from('users') .where('age > 18') .where('active = true') .order('name') .take(10) .build();
Singleton Pattern
javascriptclass Database { static #instance = null; constructor() { if (Database.#instance) { return Database.#instance; } this.connection = this.connect(); Database.#instance = this; } static getInstance() { if (!Database.#instance) { Database.#instance = new Database(); } return Database.#instance; } connect() { console.log('Connecting to database...'); return { connected: true }; } } const db1 = Database.getInstance(); const db2 = Database.getInstance(); db1 === db2; // true - same instance
Immutable Class
javascriptclass Point { #x; #y; constructor(x, y) { this.#x = x; this.#y = y; Object.freeze(this); // Prevent adding properties } get x() { return this.#x; } get y() { return this.#y; } // Return new instance instead of mutating move(dx, dy) { return new Point(this.#x + dx, this.#y + dy); } scale(factor) { return new Point(this.#x * factor, this.#y * factor); } } const p1 = new Point(1, 2); const p2 = p1.move(3, 4); // New point p1.x; // Still 1 p2.x; // 4
Common Pitfalls
- Forgetting to return
thisfor chaining: Builder pattern breaks. - Singleton with side effects in constructor: Can cause issues on import.
- Not freezing immutable objects: They can still be modified.
Best Practices
- Use arrow properties for event handlers: Auto-binds
this. - Use factories for complex construction: Cleaner than constructor overloading.
- Return
thisfor fluent APIs: Enables method chaining. - Document class invariants: Especially for immutable classes.
Summary
Use arrow function properties to auto-bind methods. Factory methods provide flexible construction. Builder pattern enables fluent APIs. Singleton ensures single instances. Immutable classes return new instances instead of mutating. These patterns make classes more practical and maintainable.
Code Examples
class Counter {
count = 0;
// Problem: 'this' is lost in callbacks
increment() {
this.count++;
}
// Solution 1: Arrow function property (most common)
decrement = () => {
this.count--;
};
}
const counter = new Counter();
// increment loses 'this'
setTimeout(counter.increment, 100); // Error!class User {
constructor(data) {
this.name = data.name;
this.email = data.email;
this.role = data.role;
}
// Factory methods
static createAdmin(name, email) {
return new User({ name, email, role: 'admin' });
}
static createGuest() {
return new User({ name: 'Guest', email: '', role: 'guest' });
}
static async fromAPI(id) {
const response = await fetch(`/api/users/${id}`);