JavaScript Classes: Introduction, Overview, and Basics

A comprehensive guide for beginners

Understanding JavaScript Classes

JavaScript classes, introduced in ECMAScript 2015 (ES6), provide a cleaner and more intuitive way to create objects and implement inheritance in JavaScript. They represent a significant evolution in how we structure and organize code, although behind the scenes, they still use JavaScript's prototype-based inheritance system.

Think of a class as a blueprint for creating objects. Just as an architect creates a blueprint before building a house, a developer creates a class before creating objects. This blueprint defines what properties and methods the resulting objects will have.

Real-world Analogy

A JavaScript class is like a cookie cutter. The class itself isn't a cookie—it's the template that defines what each cookie will look like. You can use the same cookie cutter (class) to create many similar cookies (objects), each with the same shape but potentially different decorations (property values).

Why Classes Matter in JavaScript

Before diving into the syntax, let's understand why classes have become an important part of modern JavaScript:

Organization and Structure: Classes provide a clear, organized way to create objects that share similar properties and behaviors. They help structure your code in a way that reflects real-world entities and relationships.

Encapsulation: Classes bundle data (properties) and behaviors (methods) that operate on that data into a single unit, making the code more modular and easier to understand.

Inheritance: Classes support inheritance through a more intuitive syntax than traditional prototype-based inheritance, allowing for code reuse and establishing relationships between different types of objects.

Familiarity: For developers coming from class-based languages like Java, C#, or Python, JavaScript classes provide a familiar syntax for object-oriented programming.

Modern Framework Compatibility: Many modern JavaScript frameworks and libraries, like React (for class components) and Angular, have been built with class syntax in mind.

Historical Context

Before ES6, developers created "class-like" structures using constructor functions and manipulating prototypes. This approach worked but was often confusing for newcomers and prone to errors. The new class syntax provides a cleaner, more intuitive alternative while still using JavaScript's prototype system under the hood.

Class Syntax Basics

Let's start with the fundamental syntax for defining and using classes in JavaScript.

Defining a Class

The basic structure of a class includes the class keyword, a name (conventionally starting with an uppercase letter), and a body enclosed in curly braces.

// Basic class definition
class Person {
    // Class body goes here
}

The Constructor Method

The constructor method is a special method that's called when a new object is created from the class. It's where you initialize the object's properties.

class Person {
    constructor(firstName, lastName, age) {
        // Initialize properties
        this.firstName = firstName;
        this.lastName = lastName;
        this.age = age;
    }
}

In this example, the constructor takes three parameters and assigns them to properties of the newly created object using the this keyword. The this keyword refers to the individual object that's being created.

Adding Methods

Methods are functions defined within a class that can perform actions or calculations related to the class. They're defined directly in the class body without the function keyword.

class Person {
    constructor(firstName, lastName, age) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.age = age;
    }
    
    // Method to get full name
    getFullName() {
        return `${this.firstName} ${this.lastName}`;
    }
    
    // Method to check if person is an adult
    isAdult() {
        return this.age >= 18;
    }
    
    // Method to introduce the person
    introduce() {
        return `Hello, my name is ${this.getFullName()} and I am ${this.age} years old.`;
    }
}

Creating Instances (Objects)

To create an object from a class, you use the new keyword followed by the class name and any arguments the constructor requires.

// Creating instances of the Person class
const person1 = new Person('John', 'Doe', 28);
const person2 = new Person('Jane', 'Smith', 16);

// Using the objects and their methods
console.log(person1.getFullName());  // "John Doe"
console.log(person2.getFullName());  // "Jane Smith"

console.log(person1.isAdult());      // true
console.log(person2.isAdult());      // false

console.log(person1.introduce());    // "Hello, my name is John Doe and I am 28 years old."

Analogy: Classes vs. Instances

Think of a class as a recipe and instances as the dishes you make from that recipe. The recipe (class) defines the ingredients and steps needed, but it isn't food itself. When you follow the recipe, you create an actual dish (instance) that you can eat. You can make multiple dishes from the same recipe, each with slight variations but following the same basic structure.

Working with Class Properties

Properties are the data stored in class instances. Let's explore different types of properties and how to work with them.

Instance Properties

Instance properties are unique to each object created from the class. They're typically defined and initialized in the constructor.

class Car {
    constructor(make, model, year) {
        // Instance properties
        this.make = make;
        this.model = model;
        this.year = year;
        this.mileage = 0;  // Default value
    }
}

Static Properties

Static properties belong to the class itself, not to instances. They're shared among all instances and are defined using the static keyword.

class Car {
    // Static property
    static numberOfWheels = 4;
    
    constructor(make, model, year) {
        this.make = make;
        this.model = model;
        this.year = year;
    }
}

// Accessing static property
console.log(Car.numberOfWheels);  // 4

// Instance doesn't have the static property
const myCar = new Car('Toyota', 'Corolla', 2020);
console.log(myCar.numberOfWheels);  // undefined

Private Properties (ES2022)

Modern JavaScript allows for private properties that can only be accessed from within the class. They're defined with a hash (#) prefix.

class BankAccount {
    // Private property
    #balance = 0;
    
    constructor(accountHolder) {
        this.accountHolder = accountHolder;
    }
    
    deposit(amount) {
        if (amount > 0) {
            this.#balance += amount;
            return true;
        }
        return false;
    }
    
    withdraw(amount) {
        if (amount > 0 && amount <= this.#balance) {
            this.#balance -= amount;
            return true;
        }
        return false;
    }
    
    getBalance() {
        return this.#balance;
    }
}

const account = new BankAccount('John Doe');
account.deposit(1000);
console.log(account.getBalance());  // 1000

// This would cause an error
// console.log(account.#balance);  // SyntaxError

In this example, the #balance property is private and can only be accessed or modified from within the class methods. This helps enforce encapsulation by preventing direct manipulation of important properties.

Browser Compatibility Note

Private fields (using the # prefix) are a relatively new feature in JavaScript and might not be supported in older browsers. If you need to support older environments, you can use closure-based techniques or naming conventions (like prefixing with an underscore) to indicate private properties, although these don't enforce privacy at the language level.

Understanding Class Methods

Methods are functions that are associated with a class. They define the behaviors that objects created from the class can perform.

Instance Methods

Instance methods are defined within the class and can be called on individual instances. They typically use the instance's properties and can access them via the this keyword.

class Circle {
    constructor(radius) {
        this.radius = radius;
    }
    
    // Instance methods
    getArea() {
        return Math.PI * this.radius * this.radius;
    }
    
    getCircumference() {
        return 2 * Math.PI * this.radius;
    }
    
    setRadius(newRadius) {
        if (newRadius > 0) {
            this.radius = newRadius;
        }
    }
}

const circle = new Circle(5);
console.log(circle.getArea());           // 78.54...
console.log(circle.getCircumference());  // 31.42...
circle.setRadius(10);
console.log(circle.getArea());           // 314.16...

Static Methods

Static methods are called on the class itself, not on instances. They're defined with the static keyword and typically perform utility functions related to the class.

class MathUtils {
    static add(x, y) {
        return x + y;
    }
    
    static subtract(x, y) {
        return x - y;
    }
    
    static multiply(x, y) {
        return x * y;
    }
    
    static divide(x, y) {
        if (y === 0) throw new Error("Cannot divide by zero");
        return x / y;
    }
}

// Using static methods without creating an instance
console.log(MathUtils.add(5, 3));       // 8
console.log(MathUtils.multiply(4, 2));  // 8

Static methods are useful for functionality that's related to the class conceptually but doesn't need to operate on a specific instance.

Getter and Setter Methods

JavaScript classes support special getter and setter methods that allow you to define how a property is accessed or modified. They're defined using the get and set keywords.

class Temperature {
    constructor(celsius) {
        this._celsius = celsius;
    }
    
    // Getter for celsius
    get celsius() {
        return this._celsius;
    }
    
    // Setter for celsius
    set celsius(value) {
        if (value < -273.15) {
            throw new Error("Temperature below absolute zero is not possible");
        }
        this._celsius = value;
    }
    
    // Getter for fahrenheit
    get fahrenheit() {
        return (this._celsius * 9/5) + 32;
    }
    
    // Setter for fahrenheit
    set fahrenheit(value) {
        this.celsius = (value - 32) * 5/9;
    }
}

const temp = new Temperature(25);
console.log(temp.celsius);     // 25
console.log(temp.fahrenheit);  // 77

temp.celsius = 30;
console.log(temp.celsius);     // 30
console.log(temp.fahrenheit);  // 86

temp.fahrenheit = 50;
console.log(temp.celsius);     // 10
console.log(temp.fahrenheit);  // 50

In this example:

Using Getters and Setters

When you use getters and setters, they're called like properties, not methods. Notice there are no parentheses: temp.celsius not temp.celsius(). This makes the code more natural to read and write, as it hides the fact that a method is being called behind the scenes.

Class Inheritance and Extension

One of the most powerful features of classes is inheritance, which allows you to create a new class based on an existing one. The new class inherits properties and methods from the parent class and can add or override them as needed.

Basic Inheritance with extends

To create a child class that inherits from a parent class, you use the extends keyword.

// Parent class
class Animal {
    constructor(name) {
        this.name = name;
    }
    
    speak() {
        return `${this.name} makes a noise.`;
    }
}

// Child class inheriting from Animal
class Dog extends Animal {
    constructor(name, breed) {
        // Call the parent constructor first
        super(name);
        this.breed = breed;
    }
    
    // Override the speak method
    speak() {
        return `${this.name} barks!`;
    }
    
    // Add a new method
    fetch() {
        return `${this.name} fetches the ball!`;
    }
}

// Create instances
const generic = new Animal('Animal');
const rover = new Dog('Rover', 'Golden Retriever');

console.log(generic.speak());  // "Animal makes a noise."
console.log(rover.speak());    // "Rover barks!"
console.log(rover.fetch());    // "Rover fetches the ball!"
console.log(rover.breed);      // "Golden Retriever"

The super Keyword

The super keyword is used to call functions on a parent class, including the constructor. There are two main uses:

  1. super() calls the parent class constructor
  2. super.methodName() calls a method from the parent class
class Animal {
    constructor(name) {
        this.name = name;
    }
    
    speak() {
        return `${this.name} makes a noise.`;
    }
}

class Cat extends Animal {
    constructor(name, color) {
        // Must call super() before using 'this'
        super(name);
        this.color = color;
    }
    
    speak() {
        // Call the parent's speak method first
        let message = super.speak();
        // Then extend it
        return `${message} In fact, ${this.name} meows!`;
    }
}

const whiskers = new Cat('Whiskers', 'tabby');
console.log(whiskers.speak());  // "Whiskers makes a noise. In fact, Whiskers meows!"

Important: Call super() First in Constructor

If you're using a constructor in a child class, you must call super() before using this. This initializes the parent class portion of the object before you start working with the child class properties.

Multi-level Inheritance

Classes can form inheritance chains with multiple levels.

class Animal {
    constructor(name) {
        this.name = name;
    }
    
    eat() {
        return `${this.name} eats.`;
    }
}

class Mammal extends Animal {
    constructor(name, furColor) {
        super(name);
        this.furColor = furColor;
    }
    
    warmBlooded() {
        return true;
    }
}

class Dog extends Mammal {
    constructor(name, furColor, breed) {
        super(name, furColor);
        this.breed = breed;
    }
    
    bark() {
        return `${this.name} barks loudly!`;
    }
}

const fido = new Dog('Fido', 'brown', 'Labrador');
console.log(fido.eat());         // "Fido eats." (from Animal)
console.log(fido.warmBlooded()); // true (from Mammal)
console.log(fido.bark());        // "Fido barks loudly!" (from Dog)
console.log(fido.furColor);      // "brown" (from Mammal)
console.log(fido.breed);         // "Labrador" (from Dog)

Analogy: Inheritance

Class inheritance is like genetic inheritance in biology. Just as a child inherits traits from their parents but may also develop unique characteristics, a child class inherits properties and methods from its parent class while also adding or modifying its own. Each generation can build upon what came before, creating more specialized entities.

Practical Example: Building a Library System

Let's tie everything together with a practical example: a simple library management system. This example will demonstrate how classes can model real-world entities and relationships.

// Base class for all library items
class LibraryItem {
    constructor(title, releaseYear, id) {
        this.title = title;
        this.releaseYear = releaseYear;
        this.id = id;
        this.isCheckedOut = false;
        this.checkedOutBy = null;
        this.dueDate = null;
    }
    
    checkOut(patron) {
        if (this.isCheckedOut) {
            return `Sorry, ${this.title} is already checked out.`;
        }
        
        this.isCheckedOut = true;
        this.checkedOutBy = patron;
        
        // Set due date to 2 weeks from now
        const dueDate = new Date();
        dueDate.setDate(dueDate.getDate() + 14);
        this.dueDate = dueDate;
        
        return `${this.title} has been checked out by ${patron}. Due back by ${this.dueDate.toLocaleDateString()}.`;
    }
    
    returnItem() {
        if (!this.isCheckedOut) {
            return `${this.title} is not checked out.`;
        }
        
        const returnMessage = `${this.title} has been returned.`;
        
        this.isCheckedOut = false;
        this.checkedOutBy = null;
        this.dueDate = null;
        
        return returnMessage;
    }
    
    getItemInfo() {
        return `${this.title} (${this.releaseYear}) [ID: ${this.id}]`;
    }
}

// Book class inherits from LibraryItem
class Book extends LibraryItem {
    constructor(title, author, releaseYear, pages, id) {
        super(title, releaseYear, id);
        this.author = author;
        this.pages = pages;
        this.type = 'book';
    }
    
    getItemInfo() {
        return `${this.title} by ${this.author} (${this.releaseYear}) - ${this.pages} pages [ID: ${this.id}]`;
    }
}

// DVD class inherits from LibraryItem
class DVD extends LibraryItem {
    constructor(title, director, releaseYear, runtime, id) {
        super(title, releaseYear, id);
        this.director = director;
        this.runtime = runtime;
        this.type = 'dvd';
    }
    
    getItemInfo() {
        return `${this.title} directed by ${this.director} (${this.releaseYear}) - ${this.runtime} minutes [ID: ${this.id}]`;
    }
}

// Magazine class inherits from LibraryItem
class Magazine extends LibraryItem {
    constructor(title, issue, releaseYear, publisher, id) {
        super(title, releaseYear, id);
        this.issue = issue;
        this.publisher = publisher;
        this.type = 'magazine';
    }
    
    getItemInfo() {
        return `${this.title}, Issue ${this.issue} (${this.releaseYear}) - Published by ${this.publisher} [ID: ${this.id}]`;
    }
    
    // Override checkOut to set a shorter due date for magazines
    checkOut(patron) {
        if (this.isCheckedOut) {
            return `Sorry, ${this.title} is already checked out.`;
        }
        
        this.isCheckedOut = true;
        this.checkedOutBy = patron;
        
        // Set due date to 7 days from now (shorter than the 14 days for other items)
        const dueDate = new Date();
        dueDate.setDate(dueDate.getDate() + 7);
        this.dueDate = dueDate;
        
        return `${this.title} has been checked out by ${patron}. Due back by ${this.dueDate.toLocaleDateString()}.`;
    }
}

// Library class to manage the collection
class Library {
    constructor(name) {
        this.name = name;
        this.items = [];
        this.patrons = [];
    }
    
    addItem(item) {
        this.items.push(item);
        return `${item.getItemInfo()} has been added to ${this.name}.`;
    }
    
    addPatron(name, id) {
        const patron = { name, id, itemsCheckedOut: 0 };
        this.patrons.push(patron);
        return `${name} has been registered as a patron of ${this.name}.`;
    }
    
    findItem(id) {
        return this.items.find(item => item.id === id);
    }
    
    findPatron(id) {
        return this.patrons.find(patron => patron.id === id);
    }
    
    checkOutItem(itemId, patronId) {
        const item = this.findItem(itemId);
        const patron = this.findPatron(patronId);
        
        if (!item) {
            return `Item with ID ${itemId} not found.`;
        }
        
        if (!patron) {
            return `Patron with ID ${patronId} not found.`;
        }
        
        if (item.isCheckedOut) {
            return `Sorry, ${item.title} is already checked out.`;
        }
        
        const result = item.checkOut(patron.name);
        patron.itemsCheckedOut++;
        
        return result;
    }
    
    returnItem(itemId) {
        const item = this.findItem(itemId);
        
        if (!item) {
            return `Item with ID ${itemId} not found.`;
        }
        
        if (!item.isCheckedOut) {
            return `${item.title} is not checked out.`;
        }
        
        const patron = this.findPatron(this.patrons.find(p => p.name === item.checkedOutBy)?.id);
        if (patron) {
            patron.itemsCheckedOut--;
        }
        
        return item.returnItem();
    }
    
    getAvailableItems() {
        return this.items.filter(item => !item.isCheckedOut);
    }
    
    getCheckedOutItems() {
        return this.items.filter(item => item.isCheckedOut);
    }
    
    getOverdueItems() {
        const today = new Date();
        return this.items.filter(item => 
            item.isCheckedOut && item.dueDate < today
        );
    }
    
    getPatronItemCount(patronId) {
        const patron = this.findPatron(patronId);
        if (!patron) {
            return 0;
        }
        return patron.itemsCheckedOut;
    }
    
    generateReport() {
        const totalItems = this.items.length;
        const checkedOut = this.getCheckedOutItems().length;
        const available = totalItems - checkedOut;
        const overdue = this.getOverdueItems().length;
        
        // Count by type
        const bookCount = this.items.filter(item => item.type === 'book').length;
        const dvdCount = this.items.filter(item => item.type === 'dvd').length;
        const magazineCount = this.items.filter(item => item.type === 'magazine').length;
        
        return {
            libraryName: this.name,
            totalItems,
            available,
            checkedOut,
            overdue,
            patronCount: this.patrons.length,
            itemTypes: {
                books: bookCount,
                dvds: dvdCount,
                magazines: magazineCount
            }
        };
    }
}

// Using the library system
const cityLibrary = new Library("City Public Library");

// Add some patrons
cityLibrary.addPatron("Alice Johnson", "P001");
cityLibrary.addPatron("Bob Smith", "P002");
cityLibrary.addPatron("Charlie Brown", "P003");

// Add some items
const book1 = new Book("The Great Gatsby", "F. Scott Fitzgerald", 1925, 180, "B001");
const book2 = new Book("To Kill a Mockingbird", "Harper Lee", 1960, 281, "B002");
const dvd1 = new DVD("The Shawshank Redemption", "Frank Darabont", 1994, 142, "D001");
const magazine1 = new Magazine("National Geographic", "June 2023", 2023, "National Geographic Society", "M001");

cityLibrary.addItem(book1);
cityLibrary.addItem(book2);
cityLibrary.addItem(dvd1);
cityLibrary.addItem(magazine1);

// Check out some items
console.log(cityLibrary.checkOutItem("B001", "P001"));
console.log(cityLibrary.checkOutItem("D001", "P002"));

// Generate a report
console.log(cityLibrary.generateReport());

// Return an item
console.log(cityLibrary.returnItem("B001"));

// Try to check out an already checked out item
console.log(cityLibrary.checkOutItem("D001", "P003"));

This example demonstrates many important concepts:

In our library system:

Practice Exercise: Building a School Management System

Now it's your turn to practice! Follow these steps to create a simple school management system using JavaScript classes.

Setting Up Your Files

  1. Create a new folder on your computer named school_management
  2. Inside this folder, create two files:
    • index.html
    • school.js

HTML File Setup

Open index.html and add the following code:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>School Management System</title>
</head>
<body>
    <h1>School Management System</h1>
    <p>Open the console (F12 or right-click > Inspect > Console) to see the output.</p>
    
    <script src="school.js"></script>
</body>
</html>

JavaScript Exercise

Now, open school.js and follow these steps to build a school management system:

  1. Create a base Person class with properties like name, age, and id
  2. Create Student and Teacher classes that extend the Person class
  3. Create a Course class to represent subjects taught at the school
  4. Create a School class to manage students, teachers, and courses

Here's a partial implementation to get you started:

// Base Person class
class Person {
    constructor(name, age, id) {
        this.name = name;
        this.age = age;
        this.id = id;
    }
    
    getDetails() {
        return `${this.name} (ID: ${this.id}), ${this.age} years old`;
    }
}

// Student class extending Person
class Student extends Person {
    constructor(name, age, id, grade) {
        super(name, age, id);
        this.grade = grade;
        this.courses = [];
        this.grades = {};
    }
    
    // Add a course to the student's schedule
    enrollInCourse(course) {
        if (!this.courses.includes(course)) {
            this.courses.push(course);
            this.grades[course.code] = null; // No grade yet
            return `${this.name} has enrolled in ${course.name}`;
        }
        return `${this.name} is already enrolled in ${course.name}`;
    }
    
    // Assign a grade for a specific course
    assignGrade(courseCode, grade) {
        if (this.grades.hasOwnProperty(courseCode)) {
            this.grades[courseCode] = grade;
            return `Grade ${grade} assigned to ${this.name} for ${courseCode}`;
        }
        return `${this.name} is not enrolled in ${courseCode}`;
    }
    
    // Get student's GPA
    getGPA() {
        // Your code here: Calculate the average of all grades
    }
    
    // Override getDetails to include grade
    getDetails() {
        return `${super.getDetails()}, Grade ${this.grade}`;
    }
}

// Teacher class extending Person
class Teacher extends Person {
    constructor(name, age, id, subject) {
        super(name, age, id);
        this.subject = subject;
        this.courses = [];
    }
    
    // Assign teacher to a course
    assignToCourse(course) {
        // Your code here
    }
    
    // Override getDetails to include subject
    getDetails() {
        return `${super.getDetails()}, Subject: ${this.subject}`;
    }
}

// Course class
class Course {
    constructor(name, code, maxStudents) {
        this.name = name;
        this.code = code;
        this.maxStudents = maxStudents;
        this.students = [];
        this.teacher = null;
    }
    
    // Add a student to the course
    addStudent(student) {
        // Your code here: Check if there's space and add the student
    }
    
    // Assign a teacher to the course
    assignTeacher(teacher) {
        // Your code here
    }
    
    // Get course information
    getCourseInfo() {
        let info = `${this.code}: ${this.name}`;
        info += this.teacher ? `\nTaught by: ${this.teacher.name}` : "\nNo teacher assigned";
        info += `\nStudents enrolled: ${this.students.length}/${this.maxStudents}`;
        return info;
    }
}

// School class to manage everything
class School {
    constructor(name) {
        this.name = name;
        this.students = [];
        this.teachers = [];
        this.courses = [];
    }
    
    // Add a student to the school
    addStudent(student) {
        this.students.push(student);
        return `${student.name} has been enrolled in ${this.name}`;
    }
    
    // Add a teacher to the school
    addTeacher(teacher) {
        // Your code here
    }
    
    // Add a course to the school
    addCourse(course) {
        // Your code here
    }
    
    // Find entities by ID
    findStudent(id) {
        // Your code here
    }
    
    findTeacher(id) {
        // Your code here
    }
    
    findCourse(code) {
        // Your code here
    }
    
    // Generate a school report
    generateReport() {
        // Your code here: Generate statistics about the school
    }
}

// Example usage
const mySchool = new School("Springfield High");

// Create students
const student1 = new Student("Lisa Simpson", 8, "S001", 2);
const student2 = new Student("Bart Simpson", 10, "S002", 4);
const student3 = new Student("Milhouse Van Houten", 10, "S003", 4);

// Create teachers
const teacher1 = new Teacher("Edna Krabappel", 42, "T001", "General Education");
const teacher2 = new Teacher("Dewey Largo", 38, "T002", "Music");

// Create courses
const mathCourse = new Course("Mathematics", "MATH101", 30);
const scienceCourse = new Course("Science", "SCI101", 25);
const musicCourse = new Course("Music", "MUS101", 20);

// Add them to the school
mySchool.addStudent(student1);
mySchool.addStudent(student2);
mySchool.addStudent(student3);

// Complete the implementation and test your code here

Challenge Tasks

After completing the basic implementation, try these additional challenges:

  1. Add a Classroom class with properties like room number, capacity, and assigned courses
  2. Implement a scheduling system that ensures teachers aren't double-booked
  3. Add an attendance tracking system with methods to mark students present or absent
  4. Create a GradeBook class that can calculate statistics for each course (average grade, highest grade, etc.)

Common Class Patterns in JavaScript

Beyond the basics, there are several common patterns and techniques used with classes in real-world JavaScript applications. Let's explore some of them.

Factory Pattern

A factory is a function or method that creates and returns objects. It's useful when you need to create many objects with similar properties but some variations.

class Product {
    constructor(name, price, category) {
        this.name = name;
        this.price = price;
        this.category = category;
        this.id = Math.random().toString(36).substr(2, 9);
        this.createdAt = new Date();
    }
    
    getDiscountedPrice(discountPercent) {
        return this.price * (1 - discountPercent / 100);
    }
}

// Factory function that creates different types of products
class ProductFactory {
    static createElectronic(name, price) {
        return new Product(name, price, 'electronics');
    }
    
    static createClothing(name, price) {
        return new Product(name, price, 'clothing');
    }
    
    static createBook(name, price) {
        return new Product(name, price, 'books');
    }
}

// Using the factory
const laptop = ProductFactory.createElectronic('MacBook Air', 999);
const tShirt = ProductFactory.createClothing('Cotton T-Shirt', 19.99);
const novel = ProductFactory.createBook('The Great Gatsby', 12.95);

The factory pattern helps standardize object creation and can hide complex setup logic.

Singleton Pattern

A singleton is a class that can only have one instance. This is useful for resources that should be shared throughout your application, like configuration managers or connection pools.

class DatabaseConnection {
    constructor(host, username, password) {
        if (DatabaseConnection.instance) {
            return DatabaseConnection.instance;
        }
        
        this.host = host;
        this.username = username;
        this.password = password;
        this.connected = false;
        
        // Store the instance
        DatabaseConnection.instance = this;
    }
    
    connect() {
        if (this.connected) {
            return "Already connected";
        }
        
        // In a real app, this would establish an actual database connection
        console.log(`Connecting to ${this.host} as ${this.username}...`);
        this.connected = true;
        return "Connection established";
    }
    
    disconnect() {
        if (!this.connected) {
            return "Not connected";
        }
        
        console.log("Disconnecting...");
        this.connected = false;
        return "Disconnected";
    }
    
    executeQuery(query) {
        if (!this.connected) {
            throw new Error("Must connect before executing queries");
        }
        
        console.log(`Executing: ${query}`);
        return `Results for: ${query}`;
    }
}

// First instance - sets up the singleton
const db1 = new DatabaseConnection("localhost:3306", "admin", "password123");
console.log(db1.connect());  // "Connection established"

// Second instance - returns the existing instance
const db2 = new DatabaseConnection("another-server", "user", "pass");
console.log(db2.host);  // "localhost:3306" (not "another-server")

// Both variables reference the same instance
console.log(db1 === db2);  // true

In this example, no matter how many times you try to create a new DatabaseConnection, you'll always get the same instance. This can prevent resource duplication and ensure consistent access to shared resources.

Observer Pattern

The observer pattern allows objects (observers) to be notified when another object (subject) changes. This is useful for implementing event handling and maintaining synchronized state.

class Subject {
    constructor() {
        this.observers = [];
    }
    
    subscribe(observer) {
        this.observers.push(observer);
    }
    
    unsubscribe(observer) {
        this.observers = this.observers.filter(obs => obs !== observer);
    }
    
    notify(data) {
        this.observers.forEach(observer => observer.update(data));
    }
}

class Observer {
    constructor(name) {
        this.name = name;
    }
    
    update(data) {
        console.log(`${this.name} received update: ${JSON.stringify(data)}`);
    }
}

// Example usage with a weather station
class WeatherStation extends Subject {
    constructor() {
        super();
        this.temperature = 0;
        this.humidity = 0;
    }
    
    setMeasurements(temperature, humidity) {
        this.temperature = temperature;
        this.humidity = humidity;
        
        // Notify all observers about the change
        this.notify({
            temperature: this.temperature,
            humidity: this.humidity
        });
    }
}

// Create the subject
const weatherStation = new WeatherStation();

// Create observers
const phoneApp = new Observer("Phone App");
const weatherDisplay = new Observer("Weather Display");
const weatherLogger = new Observer("Weather Logger");

// Subscribe observers to the subject
weatherStation.subscribe(phoneApp);
weatherStation.subscribe(weatherDisplay);
weatherStation.subscribe(weatherLogger);

// Change the measurements - all observers will be notified
weatherStation.setMeasurements(25.2, 65);

// Unsubscribe one observer
weatherStation.unsubscribe(weatherLogger);

// Change again - only two observers will be notified now
weatherStation.setMeasurements(26.5, 70);

This pattern is widely used in user interfaces, event handling, and maintaining synchronized state between different parts of an application.

Best Practices for Using Classes

Here are some recommended practices to make the most of JavaScript classes:

Class Design

Naming Conventions

Constructor Usage

Method Design

Method Chaining

A common pattern is to return this from methods that modify an object, allowing method calls to be chained:

class Calculator {
    constructor() {
        this.value = 0;
    }
    
    add(x) {
        this.value += x;
        return this;
    }
    
    subtract(x) {
        this.value -= x;
        return this;
    }
    
    multiply(x) {
        this.value *= x;
        return this;
    }
    
    getValue() {
        return this.value;
    }
}

// Method chaining
const result = new Calculator()
    .add(5)
    .multiply(2)
    .subtract(3)
    .getValue();

console.log(result);  // 7

Classes in Real-World Applications

JavaScript classes are widely used in modern web development. Here are some common real-world applications:

Frontend Frameworks

Many popular frameworks use classes extensively:

Data Models

Classes are excellent for modeling data entities in applications:

UI Components

Custom UI components often use classes to manage state and behavior:

Class Example: Interactive Form Validator

Here's a practical example of a form validator class you might use in a real application:

class FormValidator {
    constructor(formId, options = {}) {
        // Get the form element
        this.form = document.getElementById(formId);
        if (!this.form) {
            throw new Error(`Form with ID "${formId}" not found`);
        }
        
        // Default options
        this.options = {
            errorClass: 'error-message',
            errorPosition: 'after',  // 'after' or 'before'
            validateOnBlur: true,
            validateOnSubmit: true,
            ...options
        };
        
        // Store validation rules and error messages
        this.validations = {};
        
        // Set up event handlers if needed
        if (this.options.validateOnSubmit) {
            this.form.addEventListener('submit', this.handleSubmit.bind(this));
        }
    }
    
    // Add a validation rule for a field
    addValidation(fieldName, rules, message) {
        if (!this.validations[fieldName]) {
            this.validations[fieldName] = [];
            
            // Add blur event handler if needed
            if (this.options.validateOnBlur) {
                const field = this.form.querySelector(`[name="${fieldName}"]`);
                if (field) {
                    field.addEventListener('blur', () => this.validateField(fieldName));
                }
            }
        }
        
        this.validations[fieldName].push({ rules, message });
        return this;  // For method chaining
    }
    
    // Validate a single field
    validateField(fieldName) {
        const field = this.form.querySelector(`[name="${fieldName}"]`);
        if (!field || !this.validations[fieldName]) {
            return true;  // No field or rules to validate
        }
        
        // Remove any existing error messages
        this.clearError(field);
        
        // Get the field value
        const value = field.value;
        
        // Check each validation rule
        for (const validation of this.validations[fieldName]) {
            let isValid = true;
            
            // Apply the validation rules
            if (typeof validation.rules === 'function') {
                // Custom function validation
                isValid = validation.rules(value, field);
            } else if (validation.rules instanceof RegExp) {
                // Regular expression validation
                isValid = validation.rules.test(value);
            } else if (typeof validation.rules === 'string') {
                // Built-in validation types
                switch (validation.rules) {
                    case 'required':
                        isValid = value.trim() !== '';
                        break;
                    case 'email':
                        isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
                        break;
                    case 'number':
                        isValid = !isNaN(parseFloat(value)) && isFinite(value);
                        break;
                    // Add more built-in validations as needed
                }
            }
            
            // If validation failed, show error and return false
            if (!isValid) {
                this.showError(field, validation.message);
                return false;
            }
        }
        
        return true;  // All validations passed
    }
    
    // Validate all fields
    validateAll() {
        let isValid = true;
        
        for (const fieldName in this.validations) {
            if (!this.validateField(fieldName)) {
                isValid = false;
            }
        }
        
        return isValid;
    }
    
    // Handle form submission
    handleSubmit(event) {
        if (!this.validateAll()) {
            event.preventDefault();  // Prevent form submission if validation fails
        }
    }
    
    // Show error message
    showError(field, message) {
        // Create error element
        const errorElement = document.createElement('div');
        errorElement.className = this.options.errorClass;
        errorElement.textContent = message;
        
        // Insert error message
        if (this.options.errorPosition === 'before') {
            field.parentNode.insertBefore(errorElement, field);
        } else {
            field.parentNode.insertBefore(errorElement, field.nextSibling);
        }
    }
    
    // Clear error message
    clearError(field) {
        const parent = field.parentNode;
        const errors = parent.querySelectorAll(`.${this.options.errorClass}`);
        errors.forEach(error => parent.removeChild(error));
    }
    
    // Reset all forms and clear all errors
    reset() {
        this.form.reset();
        
        // Clear all error messages
        const errors = this.form.querySelectorAll(`.${this.options.errorClass}`);
        errors.forEach(error => error.parentNode.removeChild(error));
    }
}

// Example usage
const validator = new FormValidator('registration-form')
    .addValidation('username', 'required', 'Username is required')
    .addValidation('username', /^[a-zA-Z0-9_]{3,20}$/, 'Username must be 3-20 characters and contain only letters, numbers, and underscores')
    .addValidation('email', 'required', 'Email is required')
    .addValidation('email', 'email', 'Please enter a valid email address')
    .addValidation('password', 'required', 'Password is required')
    .addValidation('password', value => value.length >= 8, 'Password must be at least 8 characters')
    .addValidation('confirmPassword', 'required', 'Please confirm your password')
    .addValidation('confirmPassword', function(value) {
        const password = document.querySelector('[name="password"]').value;
        return value === password;
    }, 'Passwords do not match');

This form validator class demonstrates many best practices:

Classes vs. Constructor Functions

Before ES6 introduced classes, JavaScript developers used constructor functions to create object templates. Let's compare both approaches.

Constructor Function Approach

// Constructor function
function Person(name, age) {
    this.name = name;
    this.age = age;
}

// Adding methods to prototype
Person.prototype.greet = function() {
    return `Hello, my name is ${this.name}`;
};

Person.prototype.isAdult = function() {
    return this.age >= 18;
};

// Static method added to the constructor function itself
Person.createAnonymous = function() {
    return new Person('Anonymous', 30);
};

// Creating instances
var person1 = new Person('John', 25);
console.log(person1.greet());  // "Hello, my name is John"

Class Approach

// Class definition
class Person {
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }
    
    // Instance methods
    greet() {
        return `Hello, my name is ${this.name}`;
    }
    
    isAdult() {
        return this.age >= 18;
    }
    
    // Static method
    static createAnonymous() {
        return new Person('Anonymous', 30);
    }
}

// Creating instances
const person1 = new Person('John', 25);
console.log(person1.greet());  // "Hello, my name is John"

Key Differences

Under the Hood

It's important to understand that JavaScript classes are primarily syntactic sugar over the existing prototype-based inheritance. Behind the scenes, they still create constructor functions and set up the prototype chain. The class syntax simply makes this more intuitive and less error-prone.

Advanced Class Topics

After mastering the basics of classes, you can explore these more advanced topics:

Mixins

Mixins are a way to share functionality between classes without using inheritance. They allow you to "mix in" methods from other objects.

// Define a mixin with some methods
const SpeakerMixin = {
    speak(phrase) {
        console.log(`${this.name} says: ${phrase}`);
    },
    
    sayHello() {
        this.speak('Hello!');
    },
    
    sayGoodbye() {
        this.speak('Goodbye!');
    }
};

// Another mixin
const WalkerMixin = {
    walk(distance) {
        console.log(`${this.name} walked ${distance} meters`);
    },
    
    run(distance) {
        console.log(`${this.name} ran ${distance} meters very quickly`);
    }
};

// Function to apply mixins to a class
function applyMixins(targetClass, ...mixins) {
    mixins.forEach(mixin => {
        Object.getOwnPropertyNames(mixin).forEach(key => {
            targetClass.prototype[key] = mixin[key];
        });
    });
}

// Use mixins with a class
class Person {
    constructor(name) {
        this.name = name;
    }
}

// Apply both mixins to the Person class
applyMixins(Person, SpeakerMixin, WalkerMixin);

// Create an instance
const person = new Person('John');
person.sayHello();     // "John says: Hello!"
person.walk(100);      // "John walked 100 meters"
person.run(200);       // "John ran 200 meters very quickly"

Mixins allow for more flexible code reuse than inheritance alone, especially when a class needs to incorporate behavior from multiple sources.

Abstract Classes

JavaScript doesn't have built-in support for abstract classes (classes that can't be instantiated directly), but you can simulate them:

class AbstractVehicle {
    constructor() {
        if (new.target === AbstractVehicle) {
            throw new Error("Cannot instantiate abstract class");
        }
        
        this.type = 'vehicle';
    }
    
    // Abstract method - must be implemented by subclasses
    startEngine() {
        throw new Error("Method 'startEngine' must be implemented");
    }
    
    // Concrete method - shared implementation
    stopEngine() {
        console.log("Engine stopped");
    }
}

class Car extends AbstractVehicle {
    constructor(make, model) {
        super();
        this.make = make;
        this.model = model;
        this.type = 'car';
    }
    
    // Implement the abstract method
    startEngine() {
        console.log(`${this.make} ${this.model} engine started`);
    }
}

// This would throw an error:
// const vehicle = new AbstractVehicle();

// This works:
const car = new Car('Toyota', 'Corolla');
car.startEngine();  // "Toyota Corolla engine started"
car.stopEngine();   // "Engine stopped"

Abstract classes are useful for defining a common interface that subclasses must implement, while also providing shared functionality.

Metaprogramming with Classes

You can use JavaScript's metaprogramming capabilities to dynamically create or modify classes.

// Function that creates a class with specified properties
function createModelClass(name, properties) {
    return class {
        constructor(data = {}) {
            this._data = {};
            
                            // Add properties with getters and setters
            properties.forEach(prop => {
                Object.defineProperty(this, prop, {
                    get() {
                        return this._data[prop];
                    },
                    set(value) {
                        this._data[prop] = value;
                    },
                    enumerable: true
                });
                
                // Set initial value if provided
                if (data[prop] !== undefined) {
                    this[prop] = data[prop];
                }
            });
        }
        
        // Add a toJSON method for serialization
        toJSON() {
            const result = {};
            properties.forEach(prop => {
                result[prop] = this[prop];
            });
            return result;
        }
    };
}

// Use the function to create a User class
const User = createModelClass('User', ['id', 'name', 'email', 'age']);

// Create an instance
const user = new User({
    id: 1,
    name: 'John Doe',
    email: 'john@example.com',
    age: 30
});

console.log(user.name);        // "John Doe"
user.email = 'john.doe@example.com';
console.log(user.email);       // "john.doe@example.com"
console.log(JSON.stringify(user, null, 2));

This approach allows you to generate classes dynamically based on data or configuration. It's particularly useful in data-driven applications or when building frameworks.

Conclusion

JavaScript classes provide a powerful and intuitive way to create and organize object-oriented code. While they're built on JavaScript's prototype system, they offer a cleaner syntax and helpful features that make code more maintainable and easier to understand.

In this tutorial, we've covered:

As you continue your JavaScript journey, you'll find that classes are a fundamental tool for organizing complex code and building maintainable applications. They're widely used in modern JavaScript frameworks and libraries, and mastering them will significantly improve your ability to work with these technologies.

Remember that JavaScript classes are just one approach to object-oriented programming. In some cases, simpler patterns like factory functions or object composition might be more appropriate. As with any tool, the key is understanding when and how to use classes effectively to solve the problems at hand.

Happy coding!