ES6 Classes & Inheritance
Introduction to ES6 Classes
Before ES6 (ECMAScript 2015), JavaScript used constructor functions and prototypes to create objects and implement inheritance. While powerful, this approach was verbose and confusing for developers coming from class-based languages like Java, Python, or C++. ES6 introduced the class syntax as a cleaner, more intuitive way to create objects and handle inheritance. It is important to understand that ES6 classes are syntactic sugar over JavaScript's existing prototype-based inheritance -- they do not introduce a new object-oriented model. Under the hood, classes still use prototypes. However, the class syntax makes the code significantly more readable, organized, and less error-prone.
Class Syntax and the Constructor
A class is declared using the class keyword followed by a name. By convention, class names use PascalCase (first letter of each word capitalized). The constructor method is a special method that runs automatically when you create a new instance using the new keyword. It is used to initialize the object's properties. A class can have only one constructor method -- having more than one will throw a SyntaxError.
Example: Basic Class Declaration
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
}
// Create instances using the 'new' keyword
const person1 = new Person('Ahmed', 30);
const person2 = new Person('Sara', 25);
console.log(person1.name); // "Ahmed"
console.log(person2.age); // 25
// typeof reveals that classes are functions under the hood
console.log(typeof Person); // "function"
// Without 'new', you get a TypeError
// Person('Ali', 20); // TypeError: Class constructor Person cannot be invoked without 'new'
ReferenceError. This is a deliberate design choice that encourages better code organization.Class Methods
Methods defined inside a class body are added to the class's prototype, meaning all instances share the same method references rather than each instance having its own copy. You define methods without the function keyword and without commas between them. These methods are not enumerable, which means they will not show up in for...in loops.
Example: Defining Class Methods
class Calculator {
constructor(initialValue) {
this.value = initialValue || 0;
}
add(number) {
this.value += number;
return this; // enables method chaining
}
subtract(number) {
this.value -= number;
return this;
}
multiply(number) {
this.value *= number;
return this;
}
divide(number) {
if (number === 0) {
throw new Error('Cannot divide by zero');
}
this.value /= number;
return this;
}
reset() {
this.value = 0;
return this;
}
getResult() {
return this.value;
}
}
const calc = new Calculator(10);
console.log(calc.add(5).multiply(2).getResult()); // 30
// Method chaining makes code more readable
const result = new Calculator(100)
.subtract(20)
.divide(4)
.add(5)
.getResult();
console.log(result); // 25
// Methods are on the prototype, not on each instance
console.log(calc.hasOwnProperty('add')); // false
console.log(Calculator.prototype.hasOwnProperty('add')); // true
Static Methods
Static methods are defined using the static keyword. They belong to the class itself, not to instances of the class. You call them directly on the class, not on an object created from the class. Static methods are commonly used for utility functions that are related to the class but do not need access to instance-specific data. You cannot call a static method on an instance -- it will throw a TypeError.
Example: Static Methods
class MathUtils {
static add(a, b) {
return a + b;
}
static subtract(a, b) {
return a - b;
}
static isEven(number) {
return number % 2 === 0;
}
static clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
static randomBetween(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
}
// Call static methods on the class itself
console.log(MathUtils.add(5, 3)); // 8
console.log(MathUtils.isEven(4)); // true
console.log(MathUtils.clamp(15, 0, 10)); // 10
console.log(MathUtils.randomBetween(1, 100)); // random number
// Cannot call on instances
const utils = new MathUtils();
// utils.add(1, 2); // TypeError: utils.add is not a function
Static Properties
Static properties, like static methods, belong to the class itself rather than to instances. They are useful for defining constants, configuration values, or counters that should be shared across all instances. Static properties are accessed using the class name.
Example: Static Properties
class Config {
static API_URL = 'https://api.example.com';
static MAX_RETRIES = 3;
static VERSION = '2.1.0';
static instanceCount = 0;
constructor(name) {
this.name = name;
Config.instanceCount++;
}
static getInstanceCount() {
return Config.instanceCount;
}
getApiUrl() {
return Config.API_URL; // access static property from instance method
}
}
console.log(Config.API_URL); // "https://api.example.com"
console.log(Config.VERSION); // "2.1.0"
const c1 = new Config('App1');
const c2 = new Config('App2');
console.log(Config.getInstanceCount()); // 2
console.log(c1.getApiUrl()); // "https://api.example.com"
Getters and Setters
Getters and setters allow you to define methods that are accessed like properties. A getter runs when you read a property value, and a setter runs when you assign a value to a property. They are defined using the get and set keywords. Getters and setters are useful for computed properties, validation, and controlling access to internal data.
Example: Getters and Setters
class Temperature {
constructor(celsius) {
this._celsius = celsius; // underscore convention for "private" property
}
// Getter: accessed like a property, not a method call
get fahrenheit() {
return (this._celsius * 9 / 5) + 32;
}
// Setter: triggered by assignment
set fahrenheit(value) {
this._celsius = (value - 32) * 5 / 9;
}
get celsius() {
return this._celsius;
}
set celsius(value) {
if (typeof value !== 'number') {
throw new TypeError('Temperature must be a number');
}
if (value < -273.15) {
throw new RangeError('Temperature below absolute zero');
}
this._celsius = value;
}
get kelvin() {
return this._celsius + 273.15;
}
toString() {
return this._celsius.toFixed(1) + ' C';
}
}
const temp = new Temperature(100);
console.log(temp.fahrenheit); // 212 (no parentheses -- it is a getter)
console.log(temp.kelvin); // 373.15
// Use the setter by assigning a value
temp.fahrenheit = 32;
console.log(temp.celsius); // 0
// Setter validation in action
try {
temp.celsius = -300; // below absolute zero
} catch (error) {
console.log(error.message); // "Temperature below absolute zero"
}
console.log(temp.toString()); // "0.0 C"
Class Expressions
Just like functions, classes can be defined as expressions. A class expression can be named or unnamed. Named class expressions have their name available only inside the class body itself, which is useful for self-referencing but does not pollute the outer scope.
Example: Class Expressions
// Unnamed class expression
const Animal = class {
constructor(name) {
this.name = name;
}
speak() {
return this.name + ' makes a sound.';
}
};
const dog = new Animal('Rex');
console.log(dog.speak()); // "Rex makes a sound."
// Named class expression
const Vehicle = class Car {
constructor(brand) {
this.brand = brand;
}
identify() {
// 'Car' is only accessible inside the class body
return 'This is a ' + Car.name + ': ' + this.brand;
}
};
const v = new Vehicle('Toyota');
console.log(v.identify()); // "This is a Car: Toyota"
// console.log(Car); // ReferenceError: Car is not defined (outside class)
// Classes can be passed as arguments
function createInstance(ClassRef, args) {
return new ClassRef(args);
}
const myDog = createInstance(Animal, 'Buddy');
console.log(myDog.speak()); // "Buddy makes a sound."
Class Inheritance with extends
Inheritance allows you to create a new class that is based on an existing class. The new class (called the child, subclass, or derived class) inherits all properties and methods from the original class (called the parent, superclass, or base class). You establish this relationship using the extends keyword. The child class can add new properties and methods, or override inherited ones.
Example: Basic Inheritance
class Animal {
constructor(name, sound) {
this.name = name;
this.sound = sound;
}
speak() {
return this.name + ' says ' + this.sound + '!';
}
describe() {
return 'I am ' + this.name + ', an animal.';
}
}
class Dog extends Animal {
constructor(name) {
super(name, 'Woof'); // call parent constructor
this.tricks = [];
}
learnTrick(trick) {
this.tricks.push(trick);
}
showTricks() {
if (this.tricks.length === 0) {
return this.name + ' has no tricks yet.';
}
return this.name + ' can: ' + this.tricks.join(', ') + '.';
}
}
class Cat extends Animal {
constructor(name) {
super(name, 'Meow');
this.indoor = true;
}
purr() {
return this.name + ' purrs contentedly.';
}
}
const dog = new Dog('Rex');
console.log(dog.speak()); // "Rex says Woof!" (inherited)
console.log(dog.describe()); // "I am Rex, an animal." (inherited)
dog.learnTrick('sit');
dog.learnTrick('shake');
console.log(dog.showTricks()); // "Rex can: sit, shake." (own method)
const cat = new Cat('Whiskers');
console.log(cat.speak()); // "Whiskers says Meow!" (inherited)
console.log(cat.purr()); // "Whiskers purrs contentedly." (own method)
The super Keyword
The super keyword is used in two contexts within a child class. First, super() in the constructor calls the parent class's constructor. This is mandatory if the child class has a constructor -- you must call super() before using this, or you will get a ReferenceError. Second, super.methodName() calls a method from the parent class, which is useful when you want to extend rather than fully replace a parent method.
Example: Using super in Constructors and Methods
class Shape {
constructor(color) {
this.color = color;
}
describe() {
return 'A ' + this.color + ' shape';
}
area() {
return 0; // base implementation
}
}
class Circle extends Shape {
constructor(color, radius) {
// Must call super() before using 'this'
super(color);
this.radius = radius;
}
area() {
return Math.PI * this.radius * this.radius;
}
describe() {
// Call parent's describe() and extend it
return super.describe() + ' (circle with radius ' + this.radius + ')';
}
}
class Rectangle extends Shape {
constructor(color, width, height) {
super(color);
this.width = width;
this.height = height;
}
area() {
return this.width * this.height;
}
perimeter() {
return 2 * (this.width + this.height);
}
describe() {
return super.describe() + ' (rectangle ' + this.width + 'x' + this.height + ')';
}
}
const circle = new Circle('red', 5);
console.log(circle.describe()); // "A red shape (circle with radius 5)"
console.log(circle.area().toFixed(2)); // "78.54"
const rect = new Rectangle('blue', 10, 5);
console.log(rect.describe()); // "A blue shape (rectangle 10x5)"
console.log(rect.area()); // 50
console.log(rect.perimeter()); // 30
super() in a child class constructor, or trying to use this before calling super(). Both will result in a ReferenceError. If your child class does not need a constructor at all, you can omit it entirely and the parent's constructor will be called automatically.Method Overriding
When a child class defines a method with the same name as a method in the parent class, the child's method overrides the parent's. The child's version will be called when the method is invoked on instances of the child class. You can still access the parent's version using super.methodName() if you want to combine the parent's behavior with new behavior.
Example: Method Overriding Patterns
class Logger {
log(message) {
console.log('[LOG] ' + message);
}
formatDate() {
return new Date().toISOString();
}
createEntry(level, message) {
return this.formatDate() + ' [' + level + '] ' + message;
}
}
class FileLogger extends Logger {
constructor(filename) {
super();
this.filename = filename;
this.entries = [];
}
// Completely override the log method
log(message) {
const entry = this.createEntry('INFO', message);
this.entries.push(entry);
// Also call parent's log for console output
super.log(message);
}
// Override with additional behavior
formatDate() {
// Use a simpler format than the parent
const now = new Date();
return now.toLocaleDateString() + ' ' + now.toLocaleTimeString();
}
getEntries() {
return this.entries;
}
}
class ErrorLogger extends FileLogger {
constructor(filename) {
super(filename);
}
// Override log to always use ERROR level
log(message) {
const entry = this.createEntry('ERROR', message);
this.entries.push(entry);
console.error('[ERROR] ' + message);
}
// Add a new method specific to ErrorLogger
logWithStack(message) {
const stack = new Error().stack;
this.log(message + '\nStack: ' + stack);
}
}
const fileLog = new FileLogger('app.log');
fileLog.log('Application started');
fileLog.log('User logged in');
console.log(fileLog.getEntries().length); // 2
const errorLog = new ErrorLogger('error.log');
errorLog.log('Connection failed');
The instanceof Operator
The instanceof operator checks whether an object is an instance of a particular class. It also returns true for parent classes in the inheritance chain. This is useful for type checking, especially when you have a hierarchy of classes and need to determine what kind of object you are working with.
Example: Using instanceof
class Vehicle {
constructor(type) {
this.type = type;
}
}
class Car extends Vehicle {
constructor(brand) {
super('car');
this.brand = brand;
}
}
class ElectricCar extends Car {
constructor(brand, range) {
super(brand);
this.range = range;
}
}
const tesla = new ElectricCar('Tesla', 350);
console.log(tesla instanceof ElectricCar); // true
console.log(tesla instanceof Car); // true
console.log(tesla instanceof Vehicle); // true
console.log(tesla instanceof Object); // true (everything inherits from Object)
const honda = new Car('Honda');
console.log(honda instanceof Car); // true
console.log(honda instanceof ElectricCar); // false (parent is not instance of child)
// Practical use: type-based logic
function describeVehicle(vehicle) {
if (vehicle instanceof ElectricCar) {
return vehicle.brand + ' (Electric, range: ' + vehicle.range + ' mi)';
} else if (vehicle instanceof Car) {
return vehicle.brand + ' (Standard car)';
} else if (vehicle instanceof Vehicle) {
return 'A ' + vehicle.type;
}
return 'Unknown vehicle';
}
console.log(describeVehicle(tesla)); // "Tesla (Electric, range: 350 mi)"
console.log(describeVehicle(honda)); // "Honda (Standard car)"
Public and Private Class Fields
Class fields allow you to declare properties directly in the class body without putting them in the constructor. Public fields are accessible from anywhere. Private fields, prefixed with #, are truly private -- they can only be accessed from inside the class body. Attempting to access a private field from outside the class throws a SyntaxError. Private fields provide real encapsulation, unlike the older underscore convention which was merely a naming hint.
Example: Public and Private Fields
class BankAccount {
// Public fields with default values
owner = 'Unknown';
accountType = 'checking';
// Private fields -- only accessible inside the class
#balance = 0;
#transactionHistory = [];
#pin;
constructor(owner, initialBalance, pin) {
this.owner = owner;
this.#balance = initialBalance;
this.#pin = pin;
this.#recordTransaction('Account opened', initialBalance);
}
// Public method
deposit(amount) {
if (amount <= 0) {
throw new Error('Deposit amount must be positive');
}
this.#balance += amount;
this.#recordTransaction('Deposit', amount);
return this.#balance;
}
withdraw(amount, pin) {
this.#verifyPin(pin);
if (amount > this.#balance) {
throw new Error('Insufficient funds');
}
this.#balance -= amount;
this.#recordTransaction('Withdrawal', -amount);
return this.#balance;
}
getBalance(pin) {
this.#verifyPin(pin);
return this.#balance;
}
getStatement() {
return this.#transactionHistory.map(function(t) {
return t.date + ': ' + t.description + ' (' + t.amount + ')';
}).join('\n');
}
// Private methods
#verifyPin(pin) {
if (pin !== this.#pin) {
throw new Error('Invalid PIN');
}
}
#recordTransaction(description, amount) {
this.#transactionHistory.push({
date: new Date().toISOString(),
description: description,
amount: amount
});
}
}
const account = new BankAccount('Ahmed', 1000, '1234');
account.deposit(500);
console.log(account.getBalance('1234')); // 1500
account.withdraw(200, '1234');
console.log(account.getBalance('1234')); // 1300
// Public fields are accessible
console.log(account.owner); // "Ahmed"
// Private fields are NOT accessible from outside
// console.log(account.#balance); // SyntaxError
// console.log(account.#pin); // SyntaxError
// account.#verifyPin('1234'); // SyntaxError
# are a relatively new addition to JavaScript (ES2022). They are supported in all modern browsers and Node.js 12+. Unlike the underscore naming convention (_balance), # fields are enforced by the JavaScript engine -- there is no way to access them from outside the class, making them truly private.Private Methods
Private methods work the same way as private fields -- they are prefixed with # and can only be called from within the class body. They are ideal for internal helper functions that should not be part of the public API.
Example: Private Methods in Practice
class PasswordValidator {
#minLength;
#requireUppercase;
#requireNumbers;
#requireSpecial;
constructor(options) {
this.#minLength = options.minLength || 8;
this.#requireUppercase = options.requireUppercase !== false;
this.#requireNumbers = options.requireNumbers !== false;
this.#requireSpecial = options.requireSpecial || false;
}
// Public method -- the only exposed API
validate(password) {
const errors = [];
if (!this.#checkLength(password)) {
errors.push('Must be at least ' + this.#minLength + ' characters');
}
if (this.#requireUppercase && !this.#checkUppercase(password)) {
errors.push('Must contain an uppercase letter');
}
if (this.#requireNumbers && !this.#checkNumbers(password)) {
errors.push('Must contain a number');
}
if (this.#requireSpecial && !this.#checkSpecialChars(password)) {
errors.push('Must contain a special character');
}
return {
isValid: errors.length === 0,
errors: errors,
strength: this.#calculateStrength(password)
};
}
// Private helper methods
#checkLength(password) {
return password.length >= this.#minLength;
}
#checkUppercase(password) {
return /[A-Z]/.test(password);
}
#checkNumbers(password) {
return /[0-9]/.test(password);
}
#checkSpecialChars(password) {
return /[!@#$%^&*(),.?":{}|]/.test(password);
}
#calculateStrength(password) {
let score = 0;
if (password.length >= 8) score++;
if (password.length >= 12) score++;
if (/[A-Z]/.test(password)) score++;
if (/[0-9]/.test(password)) score++;
if (/[^A-Za-z0-9]/.test(password)) score++;
if (score <= 2) return 'weak';
if (score <= 3) return 'medium';
return 'strong';
}
}
const validator = new PasswordValidator({
minLength: 10,
requireSpecial: true
});
const result1 = validator.validate('hello');
console.log(result1);
// { isValid: false, errors: [...], strength: "weak" }
const result2 = validator.validate('MyStr0ng!Pass');
console.log(result2);
// { isValid: true, errors: [], strength: "strong" }
Classes vs Constructor Functions
Understanding the relationship between classes and constructor functions helps you appreciate what classes provide and how to work with older JavaScript code. Here is a side-by-side comparison showing how the same functionality is expressed in both styles.
Example: Class vs Constructor Function
// ===== Constructor Function Approach (ES5) =====
function PersonES5(name, age) {
this.name = name;
this.age = age;
}
PersonES5.prototype.greet = function() {
return 'Hello, I am ' + this.name;
};
PersonES5.create = function(name, age) {
return new PersonES5(name, age);
};
// Inheritance with constructor functions
function StudentES5(name, age, grade) {
PersonES5.call(this, name, age); // call parent constructor
this.grade = grade;
}
StudentES5.prototype = Object.create(PersonES5.prototype);
StudentES5.prototype.constructor = StudentES5;
StudentES5.prototype.study = function() {
return this.name + ' is studying.';
};
// ===== Class Approach (ES6+) =====
class PersonES6 {
constructor(name, age) {
this.name = name;
this.age = age;
}
greet() {
return 'Hello, I am ' + this.name;
}
static create(name, age) {
return new PersonES6(name, age);
}
}
class StudentES6 extends PersonES6 {
constructor(name, age, grade) {
super(name, age);
this.grade = grade;
}
study() {
return this.name + ' is studying.';
}
}
// Both produce the same results
const s1 = new StudentES5('Ali', 20, 'A');
const s2 = new StudentES6('Ali', 20, 'A');
console.log(s1.greet()); // "Hello, I am Ali"
console.log(s2.greet()); // "Hello, I am Ali"
console.log(s1.study()); // "Ali is studying."
console.log(s2.study()); // "Ali is studying."
Real-World Example: User Class
Let us build a practical User class that you might find in a real web application. It demonstrates constructors, methods, getters, setters, static methods, and private fields working together.
Example: Complete User Class
class User {
static #nextId = 1;
static roles = ['user', 'editor', 'admin'];
#id;
#password;
#loginAttempts = 0;
#locked = false;
constructor(username, email, password, role) {
this.#id = User.#nextId++;
this.username = username;
this.email = email;
this.#password = password;
this.role = User.roles.includes(role) ? role : 'user';
this.createdAt = new Date();
this.lastLogin = null;
}
get id() {
return this.#id;
}
get isLocked() {
return this.#locked;
}
get displayName() {
return '@' + this.username;
}
set email(value) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
throw new Error('Invalid email format: ' + value);
}
this._email = value;
}
get email() {
return this._email;
}
authenticate(password) {
if (this.#locked) {
throw new Error('Account is locked. Contact support.');
}
if (password === this.#password) {
this.#loginAttempts = 0;
this.lastLogin = new Date();
return true;
}
this.#loginAttempts++;
if (this.#loginAttempts >= 5) {
this.#locked = true;
throw new Error('Account locked after 5 failed attempts.');
}
return false;
}
changePassword(oldPassword, newPassword) {
if (oldPassword !== this.#password) {
throw new Error('Current password is incorrect');
}
if (newPassword.length < 8) {
throw new Error('New password must be at least 8 characters');
}
this.#password = newPassword;
}
hasPermission(action) {
const permissions = {
user: ['read'],
editor: ['read', 'write', 'edit'],
admin: ['read', 'write', 'edit', 'delete', 'manage']
};
return permissions[this.role].includes(action);
}
toJSON() {
return {
id: this.#id,
username: this.username,
email: this.email,
role: this.role,
createdAt: this.createdAt,
lastLogin: this.lastLogin
};
}
static findByEmail(users, email) {
return users.find(function(user) {
return user.email === email;
}) || null;
}
}
const admin = new User('ahmed', 'ahmed@example.com', 'SecurePass1', 'admin');
console.log(admin.id); // 1
console.log(admin.displayName); // "@ahmed"
console.log(admin.hasPermission('delete')); // true
admin.authenticate('SecurePass1');
console.log(admin.lastLogin); // current date
console.log(JSON.stringify(admin.toJSON(), null, 2));
Real-World Example: Product and ShoppingCart
Here is a more complex example showing how multiple classes work together. The Product class represents items, and the ShoppingCart class manages a collection of products with quantities. This demonstrates inheritance, composition, static methods, and encapsulation in a practical e-commerce scenario.
Example: Product and ShoppingCart Classes
class Product {
#id;
#price;
static #catalog = [];
constructor(id, name, price, category) {
this.#id = id;
this.name = name;
this.#price = price;
this.category = category;
this.inStock = true;
Product.#catalog.push(this);
}
get id() {
return this.#id;
}
get price() {
return this.#price;
}
set price(newPrice) {
if (newPrice < 0) {
throw new Error('Price cannot be negative');
}
this.#price = newPrice;
}
getFormattedPrice() {
return '$' + this.#price.toFixed(2);
}
toString() {
return this.name + ' (' + this.getFormattedPrice() + ')';
}
static getCatalog() {
return [...Product.#catalog];
}
static findById(id) {
return Product.#catalog.find(function(p) {
return p.id === id;
}) || null;
}
static findByCategory(category) {
return Product.#catalog.filter(function(p) {
return p.category === category;
});
}
}
class DigitalProduct extends Product {
#downloadUrl;
#fileSize;
constructor(id, name, price, category, downloadUrl, fileSize) {
super(id, name, price, category);
this.#downloadUrl = downloadUrl;
this.#fileSize = fileSize;
}
getDownloadLink() {
return this.#downloadUrl;
}
getFileSize() {
if (this.#fileSize < 1024) {
return this.#fileSize + ' KB';
}
return (this.#fileSize / 1024).toFixed(1) + ' MB';
}
toString() {
return super.toString() + ' [Digital - ' + this.getFileSize() + ']';
}
}
class ShoppingCart {
#items = [];
#discountCode = null;
#discountPercent = 0;
addItem(product, quantity) {
if (quantity === undefined) quantity = 1;
if (!(product instanceof Product)) {
throw new Error('Item must be a Product instance');
}
if (!product.inStock) {
throw new Error(product.name + ' is out of stock');
}
const existing = this.#items.find(function(item) {
return item.product.id === product.id;
});
if (existing) {
existing.quantity += quantity;
} else {
this.#items.push({ product: product, quantity: quantity });
}
return this;
}
removeItem(productId) {
this.#items = this.#items.filter(function(item) {
return item.product.id !== productId;
});
return this;
}
updateQuantity(productId, quantity) {
if (quantity <= 0) {
return this.removeItem(productId);
}
const item = this.#items.find(function(item) {
return item.product.id === productId;
});
if (item) {
item.quantity = quantity;
}
return this;
}
applyDiscount(code, percent) {
this.#discountCode = code;
this.#discountPercent = Math.min(percent, 50); // max 50% discount
return this;
}
get subtotal() {
return this.#items.reduce(function(sum, item) {
return sum + (item.product.price * item.quantity);
}, 0);
}
get discount() {
return this.subtotal * (this.#discountPercent / 100);
}
get total() {
return this.subtotal - this.discount;
}
get itemCount() {
return this.#items.reduce(function(count, item) {
return count + item.quantity;
}, 0);
}
getSummary() {
let summary = 'Shopping Cart (' + this.itemCount + ' items):\n';
summary += '----------------------------\n';
this.#items.forEach(function(item) {
const lineTotal = item.product.price * item.quantity;
summary += item.product.name + ' x' + item.quantity;
summary += ' = $' + lineTotal.toFixed(2) + '\n';
});
summary += '----------------------------\n';
summary += 'Subtotal: $' + this.subtotal.toFixed(2) + '\n';
if (this.#discountPercent > 0) {
summary += 'Discount (' + this.#discountCode + '): -$';
summary += this.discount.toFixed(2) + '\n';
}
summary += 'Total: $' + this.total.toFixed(2);
return summary;
}
clear() {
this.#items = [];
this.#discountCode = null;
this.#discountPercent = 0;
}
}
// Usage
const laptop = new Product(1, 'Laptop', 999.99, 'electronics');
const mouse = new Product(2, 'Mouse', 29.99, 'electronics');
const ebook = new DigitalProduct(3, 'JS Guide', 19.99, 'books',
'https://example.com/download/js-guide', 2500);
const cart = new ShoppingCart();
cart.addItem(laptop, 1)
.addItem(mouse, 2)
.addItem(ebook, 1)
.applyDiscount('SAVE10', 10);
console.log(cart.getSummary());
// Shopping Cart (4 items):
// ----------------------------
// Laptop x1 = $999.99
// Mouse x2 = $59.98
// JS Guide x1 = $19.99
// ----------------------------
// Subtotal: $1079.96
// Discount (SAVE10): -$108.00
// Total: $971.96
console.log(ebook.toString()); // "JS Guide ($19.99) [Digital - 2.4 MB]"
// Using static methods
console.log(Product.findByCategory('electronics').length); // 2
Inheritance Chain and Multi-Level Inheritance
JavaScript supports multi-level inheritance, where a class extends another class that itself extends a parent. The prototype chain links all the way up, allowing an instance to access methods from any ancestor class. While deep inheritance chains are possible, it is generally best practice to keep hierarchies shallow (two or three levels deep) to maintain code readability.
Example: Multi-Level Inheritance
class Component {
constructor(id) {
this.id = id;
this.visible = true;
}
show() {
this.visible = true;
return this;
}
hide() {
this.visible = false;
return this;
}
toString() {
return 'Component#' + this.id;
}
}
class InteractiveComponent extends Component {
#eventHandlers = {};
constructor(id) {
super(id);
this.enabled = true;
}
on(event, handler) {
if (!this.#eventHandlers[event]) {
this.#eventHandlers[event] = [];
}
this.#eventHandlers[event].push(handler);
return this;
}
trigger(event, data) {
const handlers = this.#eventHandlers[event] || [];
handlers.forEach(function(handler) {
handler(data);
});
return this;
}
disable() {
this.enabled = false;
return this;
}
enable() {
this.enabled = true;
return this;
}
}
class Button extends InteractiveComponent {
constructor(id, label) {
super(id);
this.label = label;
}
click() {
if (this.enabled && this.visible) {
this.trigger('click', { buttonId: this.id, label: this.label });
}
}
toString() {
return 'Button#' + this.id + ' (' + this.label + ')';
}
}
const submitBtn = new Button('btn-1', 'Submit');
submitBtn.on('click', function(data) {
console.log('Button clicked: ' + data.label);
});
submitBtn.click(); // "Button clicked: Submit"
submitBtn.disable();
submitBtn.click(); // nothing happens -- button is disabled
submitBtn.enable();
submitBtn.click(); // "Button clicked: Submit"
// Inheritance chain verification
console.log(submitBtn instanceof Button); // true
console.log(submitBtn instanceof InteractiveComponent); // true
console.log(submitBtn instanceof Component); // true
console.log(submitBtn.toString()); // "Button#btn-1 (Submit)"
Practice Exercise
Build a Task Management System using ES6 classes. Create the following class hierarchy: (1) A base Task class with private fields for id, title, description, status (pending, in-progress, completed), priority (low, medium, high), createdAt, and completedAt. Include methods for updating status, changing priority, and a toString method. Use getters for all private fields and setters with validation for status and priority. (2) A TimedTask class that extends Task and adds a deadline, a method to check if the task is overdue, and a method to get the remaining time. (3) A RecurringTask class that extends Task and adds a recurrence pattern (daily, weekly, monthly), a method to generate the next occurrence, and a counter for how many times it has been completed. (4) A TaskBoard class that manages a collection of tasks with methods to add tasks, remove tasks, filter by status, filter by priority, sort by deadline or priority, get statistics (total, completed, overdue), and search by title or description. Use private fields, static methods for creating tasks with auto-incrementing IDs, and method chaining where appropriate. Test your system by creating multiple tasks of different types, changing their statuses, filtering and sorting the task board, and verifying that private fields cannot be accessed from outside the classes.