JavaScript Essentials

ES6 Classes & Inheritance

45 min Lesson 34 of 60

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'
Note: Unlike function declarations, class declarations are NOT hoisted. You must declare a class before you can use it. Trying to instantiate a class before its declaration will throw a 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"
Pro Tip: Getters and setters are a great way to maintain backward compatibility. If you start with a simple public property and later need to add validation or computation, you can replace it with a getter and setter without changing the code that uses the class. The external API stays the same.

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
Common Mistake: Forgetting to call 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
Note: Private fields with # 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."
Pro Tip: Always prefer the ES6 class syntax for new code. It is cleaner, less error-prone, and universally understood. The constructor function pattern is still found in older libraries and codebases, so being familiar with both is valuable, but there is no reason to use constructor functions in modern JavaScript.

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)"
Warning: Avoid deeply nested inheritance chains (more than three levels). Deep hierarchies become hard to understand, debug, and maintain. If you find yourself creating many levels of inheritance, consider using composition instead -- where a class contains instances of other classes rather than extending them. A common saying in software design is "favor composition over inheritance."
Pro Tip: When designing classes, follow the Single Responsibility Principle: each class should have one clear purpose. A User class handles user data and authentication. A ShoppingCart class handles cart operations. A Product class handles product information. Resist the urge to put unrelated functionality into a single class just because it seems convenient.

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.