Class Syntax: Modern JavaScript Object-Oriented Programming
In modern JavaScript development, organizing code into clear, reusable structures is crucial. Although JavaScript's prototype-based nature is very powerful, directly manipulating prototypes often seems tedious and obscure. ES6 introduced the class syntax, providing us with a way closer to traditional object-oriented languages to define object blueprints, making code cleaner, more intuitive, and easier to maintain.
Basic Class Syntax
Basic Class Definition
// Basic class definition
class Person {
// Constructor
constructor(name, age) {
this.name = name;
this.age = age;
}
// Instance method
greet() {
console.log(`Hello, I'm ${this.name}`);
}
// Another instance method
celebrateBirthday() {
this.age++;
console.log(`Happy birthday! Now I'm ${this.age} years old`);
}
}
// Create instance
const john = new Person("John", 25);
john.greet(); // Hello, I'm John
john.celebrateBirthday(); // Happy birthday! Now I'm 26 years old
console.log(john.name); // John
console.log(john.age); // 26Class Declaration vs Class Expression
// Class declaration
class Animal {
constructor(species) {
this.species = species;
}
}
// Class expression (anonymous)
const Vehicle = class {
constructor(type) {
this.type = type;
}
};
// Class expression (named)
const Plant = class Flower {
constructor(name) {
this.name = name;
}
};
const rose = new Flower("Rose");
console.log(rose.name); // RoseThe Essence of Class
Despite using new syntax, JavaScript's Class is essentially syntactic sugar, with the underlying mechanism still based on prototypes:
class Person {
constructor(name) {
this.name = name;
}
greet() {
console.log(`Hello, I'm ${this.name}`);
}
}
// Equivalent traditional approach
function Person(name) {
this.name = name;
}
Person.prototype.greet = function () {
console.log(`Hello, I'm ${this.name}`);
};
// Verify prototype relationship
const person = new Person("John");
console.log(person.__proto__ === Person.prototype); // true
console.log(typeof Person); // functionInheritance
extends Keyword
// Parent class
class Animal {
constructor(name, species) {
this.name = name;
this.species = species;
}
speak() {
console.log(`${this.name} the ${this.species} makes a sound`);
}
eat() {
console.log(`${this.name} is eating`);
}
}
// Child class
class Dog extends Animal {
constructor(name, breed) {
// Call parent constructor
super(name, "dog");
this.breed = breed;
}
// Override parent method
speak() {
console.log(`${this.name} the ${this.breed} barks: Woof!`);
}
// Add new method
fetch() {
console.log(`${this.name} is fetching the ball`);
}
// Call parent method
eat() {
// Call parent's eat method
super.eat();
console.log(`${this.name} enjoys dog food`);
}
}
const buddy = new Dog("Buddy", "Golden Retriever");
// Call overridden method
buddy.speak(); // Buddy the Golden Retriever barks: Woof!
// Call child's new method
buddy.fetch(); // Buddy is fetching the ball
// Call enhanced parent method
buddy.eat();
// Buddy is eating
// Buddy enjoys dog foodInheritance Chain Verification
class Animal {}
class Dog extends Animal {}
class GoldenRetriever extends Dog {}
const dog = new GoldenRetriever();
console.log(dog instanceof GoldenRetriever); // true
console.log(dog instanceof Dog); // true
console.log(dog instanceof Animal); // true
console.log(dog instanceof Object); // true
// Prototype chain relationships
console.log(Object.getPrototypeOf(dog) === GoldenRetriever.prototype); // true
console.log(Object.getPrototypeOf(GoldenRetriever.prototype) === Dog.prototype); // true
console.log(Object.getPrototypeOf(Dog.prototype) === Animal.prototype); // trueMixin Pattern
// Define mixin classes
const canFly = {
fly() {
console.log(`${this.name} is flying`);
},
land() {
console.log(`${this.name} is landing`);
},
};
const canSwim = {
swim() {
console.log(`${this.name} is swimming`);
},
dive() {
console.log(`${this.name} is diving`);
},
};
// Use mixins
class Bird {
constructor(name) {
this.name = name;
}
}
// Apply mixins
Object.assign(Bird.prototype, canFly);
class Duck extends Bird {
constructor(name) {
super(name);
}
}
// Duck inherits from Bird and applies canFly
const duck = new Duck("Donald");
duck.fly(); // Donald is flying
// For multiple mixins, can create helper function
function applyMixins(targetClass, ...mixins) {
mixins.forEach((mixin) => {
Object.getOwnPropertyNames(mixin).forEach((name) => {
if (name !== "constructor") {
targetClass.prototype[name] = mixin[name];
}
});
});
}
// Apply multiple mixins
class AmphibiousCar {
constructor(name) {
this.name = name;
}
}
applyMixins(AmphibiousCar, canSwim, canFly);
const amphiCar = new AmphibiousCar("AmphiCar");
amphiCar.swim(); // AmphiCar is swimming
amphiCar.fly(); // AmphiCar is flyingStatic Methods and Static Properties
Static Methods
class MathHelper {
// Instance method
multiply(a, b) {
return a * b;
}
// Static method
static add(a, b) {
return a + b;
}
static subtract(a, b) {
return a - b;
}
// Static factory method
static createMultiplier(factor) {
return new (class {
multiply(x) {
return x * factor;
}
})();
}
}
// Call static methods (no instance needed)
console.log(MathHelper.add(5, 3)); // 8
console.log(MathHelper.subtract(10, 3)); // 7
// Create instance
const math = new MathHelper();
console.log(math.multiply(4, 3)); // 12
// Static factory method
const doubleMultiplier = MathHelper.createMultiplier(2);
console.log(doubleMultiplier.multiply(5)); // 10
// Static methods are not inherited by subclasses
class AdvancedMath extends MathHelper {}
console.log(AdvancedMath.add(2, 3)); // 8 (accessed through prototype chain)
console.log(AdvancedMath.subtract(5, 2)); // 3Static Properties
class Counter {
// Static properties (ES2022)
static count = 0;
static instances = [];
constructor(name) {
this.name = name;
this.id = Counter.count++;
Counter.instances.push(this);
}
// Static getter
static get totalCount() {
return this.count;
}
static get allInstances() {
return [...this.instances];
}
// Static method
static reset() {
this.count = 0;
this.instances.length = 0;
}
static getInstanceCount() {
return this.instances.length;
}
}
const counter1 = new Counter("Counter 1");
const counter2 = new Counter("Counter 2");
const counter3 = new Counter("Counter 3");
console.log(Counter.totalCount); // 3
console.log(Counter.getInstanceCount()); // 3
console.log(Counter.allInstances.length); // 3
Counter.reset();
console.log(Counter.totalCount); // 0
console.log(Counter.getInstanceCount()); // 0Accessor Properties
Getter and Setter
class Person {
constructor(firstName, lastName, age) {
this._firstName = firstName;
this._lastName = lastName;
this._age = age;
}
// Getter for fullName
get fullName() {
return `${this._firstName} ${this._lastName}`;
}
// Setter for fullName
set fullName(name) {
const parts = name.split(" ");
if (parts.length === 2) {
this._firstName = parts[0];
this._lastName = parts[1];
} else {
throw new Error("Please provide both first and last name");
}
}
// Getter for age
get age() {
return this._age;
}
// Setter for age with validation
set age(newAge) {
if (typeof newAge !== "number" || newAge < 0 || newAge > 150) {
throw new Error("Age must be a number between 0 and 150");
}
this._age = newAge;
}
// Read-only property
get isAdult() {
return this._age >= 18;
}
}
const person = new Person("John", "Doe", 25);
console.log(person.fullName); // John Doe
console.log(person.age); // 25
console.log(person.isAdult); // true
// Use setter
person.fullName = "Jane Smith";
console.log(person.fullName); // Jane Smith
console.log(person._firstName); // Jane
// Use validation
person.age = 30; // Normal
console.log(person.age); // 30
try {
person.age = -5; // Throws error
} catch (error) {
console.log(error.message); // Age must be a number between 0 and 150
}Private Fields and Methods
Private Fields (ES2022)
class BankAccount {
// Private fields (starting with #)
#balance;
#accountNumber;
#transactions = [];
constructor(accountNumber, initialBalance = 0) {
this.#accountNumber = accountNumber;
this.#balance = initialBalance;
}
// Public methods
deposit(amount) {
if (amount <= 0) {
throw new Error("Deposit amount must be positive");
}
this.#balance += amount;
this.#transactions.push({ type: "deposit", amount, date: new Date() });
return this.#balance;
}
withdraw(amount) {
if (amount <= 0) {
throw new Error("Withdrawal amount must be positive");
}
if (amount > this.#balance) {
throw new Error("Insufficient funds");
}
this.#balance -= amount;
this.#transactions.push({ type: "withdrawal", amount, date: new Date() });
return this.#balance;
}
// Access private fields
getBalance() {
return this.#balance;
}
getAccountNumber() {
return this.#accountNumber;
}
getTransactionHistory() {
return [...this.#transactions]; // Return copy
}
// Private method
#validateAmount(amount) {
return typeof amount === "number" && amount > 0;
}
// Private getter
get #isOverdrawn() {
return this.#balance < 0;
}
// Use private method
processTransaction(amount, type) {
if (!this.#validateAmount(amount)) {
throw new Error("Invalid amount");
}
if (type === "deposit") {
return this.deposit(amount);
} else if (type === "withdrawal") {
return this.withdraw(amount);
}
}
}
const account = new BankAccount("123456789", 1000);
// Public access
console.log(account.getBalance()); // 1000
account.deposit(500);
console.log(account.getBalance()); // 1500
// Private fields cannot be accessed from outside
console.log(account.#balance); // SyntaxError: Private field '#balance' must be declared in an enclosing class
console.log(account.#accountNumber); // SyntaxError: Private field '#accountNumber' must be declared in an enclosing classTraditional Private Pattern (Compatible with Older Versions)
class TraditionalPrivate {
constructor(name) {
// Store private data in weak map
this._privateData = new Map();
this._privateData.set(this, {
name: name,
secrets: [],
});
}
getName() {
return this._privateData.get(this).name;
}
setName(name) {
this._privateData.get(this).name = name;
}
addSecret(secret) {
this._privateData.get(this).secrets.push(secret);
}
getSecrets() {
return [...this._privateData.get(this).secrets];
}
}
// Or use closure pattern
function createPrivateClass() {
const privateData = new WeakMap();
return class PrivateExample {
constructor(name) {
privateData.set(this, { name, secrets: [] });
}
getName() {
return privateData.get(this).name;
}
addSecret(secret) {
privateData.get(this).secrets.push(secret);
}
};
}
const PrivateExample = createPrivateClass();Advanced Method Usage
Bound Methods
class Button {
constructor(text) {
this.text = text;
// Ensure this is bound correctly
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
console.log(`Button "${this.text}" clicked`);
}
// Arrow function method (auto-bound)
handleMouseOver = () => {
console.log(`Mouse over "${this.text}"`);
};
// Regular method (needs binding)
handleMouseOut() {
console.log(`Mouse out "${this.text}"`);
}
}
const button = new Button("Click Me");
// DOM event listening
// document.getElementById('myButton').addEventListener('click', button.handleClick);
// document.getElementById('myButton').addEventListener('mouseover', button.handleMouseOver);
// document.getElementById('myButton').addEventListener('mouseout', button.handleMouseOut.bind(button));Computed Property Names
const methodName = "greet";
const propertyName = "fullName";
class Person {
constructor(firstName, lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
// Computed method name
[methodName]() {
console.log(`Hello, I'm ${this.firstName}`);
}
// Computed getter name
get [propertyName]() {
return `${this.firstName} ${this.lastName}`;
}
// Computed setter name
set [propertyName](name) {
const parts = name.split(" ");
this.firstName = parts[0] || "";
this.lastName = parts[1] || "";
}
}
const person = new Person("John", "Doe");
person.greet(); // Hello, I'm John
console.log(person.fullName); // John Doe
person.fullName = "Jane Smith";
console.log(person.fullName); // Jane SmithBuilt-in Class Features
new.target
class Shape {
constructor(type) {
this.type = type;
// Check if called with new
if (!new.target) {
throw new Error("Shape must be called with new");
}
// new.target points to the constructor being new'd
console.log("Creating instance of:", new.target.name);
}
}
class Circle extends Shape {
constructor(radius) {
// When calling parent constructor, new.target is Circle
super("circle");
this.radius = radius;
}
}
const shape = new Shape("polygon"); // Creating instance of: Shape
const circle = new Circle(5); // Creating instance of: Circle
// Abstract class pattern
class Animal {
constructor() {
if (new.target === Animal) {
throw new Error("Animal is abstract and cannot be instantiated directly");
}
}
speak() {
throw new Error("Method must be implemented by subclass");
}
}
class Dog extends Animal {
speak() {
console.log("Woof!");
}
}
const dog = new Dog();
dog.speak(); // Woof!
// const animal = new Animal(); // Error: Animal is abstract and cannot be instantiated directlySymbol.species
class MyArray extends Array {
static get [Symbol.species]() {
return Array; // Override species to Array instead of MyArray
}
// Custom methods
first() {
return this[0];
}
last() {
return this[this.length - 1];
}
}
const myArray = new MyArray(1, 2, 3, 4, 5);
// map method returns regular Array instead of MyArray
const mapped = myArray.map((x) => x * 2);
console.log(mapped instanceof MyArray); // false
console.log(mapped instanceof Array); // true
// Custom methods still work
console.log(myArray.first()); // 1
console.log(myArray.last()); // 5Practical Application Examples
Data Model Class
class UserModel {
#id;
#email;
#name;
#createdAt;
#updatedAt;
constructor({ id, email, name }) {
this.#id = id || this.#generateId();
this.#email = this.#validateEmail(email);
this.#name = name;
this.#createdAt = new Date();
this.#updatedAt = new Date();
}
// Public accessors
get id() {
return this.#id;
}
get email() {
return this.#email;
}
get name() {
return this.#name;
}
get createdAt() {
return this.#createdAt;
}
get updatedAt() {
return this.#updatedAt;
}
// Setters
set email(newEmail) {
this.#email = this.#validateEmail(newEmail);
this.#updatedAt = new Date();
}
set name(newName) {
this.#name = newName;
this.#updatedAt = new Date();
}
// Instance methods
update(data) {
if (data.email) this.email = data.email;
if (data.name) this.name = data.name;
return this;
}
toJSON() {
return {
id: this.#id,
email: this.#email,
name: this.#name,
createdAt: this.#createdAt,
updatedAt: this.#updatedAt,
};
}
// Private methods
#generateId() {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
}
#validateEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw new Error("Invalid email format");
}
return email;
}
// Static methods
static create(userData) {
return new UserModel(userData);
}
static fromJSON(json) {
const data = typeof json === "string" ? JSON.parse(json) : json;
return new UserModel(data);
}
}
// Usage example
const user = UserModel.create({
email: "[email protected]",
name: "John Doe",
});
console.log(user.email); // [email protected]
console.log(user.name); // John Doe
user.update({ name: "John Smith" });
console.log(user.name); // John Smith
const userJSON = user.toJSON();
console.log(userJSON);
const newUser = UserModel.fromJSON(userJSON);
console.log(newUser.email); // [email protected]State Management Class
class StateManager {
#state = {};
#listeners = new Map();
#history = [];
#maxHistorySize = 50;
constructor(initialState = {}) {
this.#state = { ...initialState };
this.#saveToHistory();
}
// Get state
getState(path) {
if (!path) return { ...this.#state };
return path.split(".").reduce((obj, key) => obj?.[key], this.#state);
}
// Set state
setState(path, value) {
const oldValue = this.getState(path);
const newValue = typeof path === "string" ? value : path;
if (typeof path === "string") {
// Update nested property
this.#updateNestedProperty(path, newValue);
} else {
// Update entire state
this.#state = { ...this.#state, ...newValue };
}
this.#saveToHistory();
this.#notifyListeners(path, newValue, oldValue);
return this;
}
// Subscribe to state changes
subscribe(path, callback) {
if (!this.#listeners.has(path)) {
this.#listeners.set(path, new Set());
}
this.#listeners.get(path).add(callback);
// Return unsubscribe function
return () => {
const listeners = this.#listeners.get(path);
if (listeners) {
listeners.delete(callback);
if (listeners.size === 0) {
this.#listeners.delete(path);
}
}
};
}
// Undo
undo() {
if (this.#history.length > 1) {
this.#history.pop(); // Remove current state
const previousState = this.#history[this.#history.length - 1];
this.#state = { ...previousState };
return true;
}
return false;
}
// Redo
redo() {
// Implement redo logic
}
// Clear history
clearHistory() {
this.#history = [{ ...this.#state }];
return this;
}
// Private methods
#updateNestedProperty(path, value) {
const keys = path.split(".");
const lastKey = keys.pop();
const target = keys.reduce((obj, key) => {
if (!obj[key]) obj[key] = {};
return obj[key];
}, this.#state);
target[lastKey] = value;
}
#saveToHistory() {
this.#history.push({ ...this.#state });
if (this.#history.length > this.#maxHistorySize) {
this.#history.shift();
}
}
#notifyListeners(path, newValue, oldValue) {
// Notify specific path listeners
const specificListeners = this.#listeners.get(path);
if (specificListeners) {
specificListeners.forEach((callback) => {
try {
callback(newValue, oldValue, path);
} catch (error) {
console.error("Listener error:", error);
}
});
}
// Notify wildcard listeners
const wildcardListeners = this.#listeners.get("*");
if (wildcardListeners) {
wildcardListeners.forEach((callback) => {
try {
callback(this.#state, { path, newValue, oldValue });
} catch (error) {
console.error("Wildcard listener error:", error);
}
});
}
}
}
// Usage example
const stateManager = new StateManager({
user: { name: "John", age: 25 },
theme: "light",
});
// Subscribe to state changes
const unsubscribeUser = stateManager.subscribe("user", (newUser, oldUser) => {
console.log("User changed:", { from: oldUser, to: newUser });
});
const unsubscribeTheme = stateManager.subscribe(
"theme",
(newTheme, oldTheme) => {
console.log("Theme changed:", { from: oldTheme, to: newTheme });
}
);
// Update state
stateManager.setState("user.name", "Jane");
stateManager.setState("theme", "dark");
// Undo
stateManager.undo();Summary
JavaScript's Class syntax provides a clearer, more intuitive syntax for object-oriented programming while maintaining the underlying prototype-based mechanism:
- Clear syntax structure: Use
class,constructor,extendsand other keywords - Simplified inheritance mechanism: Easily implement inheritance through
extendsandsuper - Private field support: Use
#syntax to define truly private fields - Static methods and properties: Support class-level members
- Accessor properties: Provide flexible property access through getters and setters
- Backward compatibility: Still based on prototype inheritance, compatible with existing code
Although Class syntax makes JavaScript look more like traditional object-oriented languages, it's important to understand it's still based on prototype inheritance. Mastering Class syntax while understanding the underlying mechanisms helps us write more modern, maintainable JavaScript code.