Skip to content

Class Syntax: Modern JavaScript Object-Oriented Programming

In modern JavaScript development, organizing code into clear, reusable structures is crucial. Although JavaScript's prototype-based nature is very powerful, directly manipulating prototypes often seems tedious and obscure. ES6 introduced the class syntax, providing us with a way closer to traditional object-oriented languages to define object blueprints, making code cleaner, more intuitive, and easier to maintain.

Basic Class Syntax

Basic Class Definition

javascript
// Basic class definition
class Person {
  // Constructor
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }

  // Instance method
  greet() {
    console.log(`Hello, I'm ${this.name}`);
  }

  // Another instance method
  celebrateBirthday() {
    this.age++;
    console.log(`Happy birthday! Now I'm ${this.age} years old`);
  }
}

// Create instance
const john = new Person("John", 25);
john.greet(); // Hello, I'm John
john.celebrateBirthday(); // Happy birthday! Now I'm 26 years old

console.log(john.name); // John
console.log(john.age); // 26

Class Declaration vs Class Expression

javascript
// Class declaration
class Animal {
  constructor(species) {
    this.species = species;
  }
}

// Class expression (anonymous)
const Vehicle = class {
  constructor(type) {
    this.type = type;
  }
};

// Class expression (named)
const Plant = class Flower {
  constructor(name) {
    this.name = name;
  }
};

const rose = new Flower("Rose");
console.log(rose.name); // Rose

The Essence of Class

Despite using new syntax, JavaScript's Class is essentially syntactic sugar, with the underlying mechanism still based on prototypes:

javascript
class Person {
  constructor(name) {
    this.name = name;
  }

  greet() {
    console.log(`Hello, I'm ${this.name}`);
  }
}

// Equivalent traditional approach
function Person(name) {
  this.name = name;
}

Person.prototype.greet = function () {
  console.log(`Hello, I'm ${this.name}`);
};

// Verify prototype relationship
const person = new Person("John");
console.log(person.__proto__ === Person.prototype); // true
console.log(typeof Person); // function

Inheritance

extends Keyword

javascript
// Parent class
class Animal {
  constructor(name, species) {
    this.name = name;
    this.species = species;
  }

  speak() {
    console.log(`${this.name} the ${this.species} makes a sound`);
  }

  eat() {
    console.log(`${this.name} is eating`);
  }
}

// Child class
class Dog extends Animal {
  constructor(name, breed) {
    // Call parent constructor
    super(name, "dog");
    this.breed = breed;
  }

  // Override parent method
  speak() {
    console.log(`${this.name} the ${this.breed} barks: Woof!`);
  }

  // Add new method
  fetch() {
    console.log(`${this.name} is fetching the ball`);
  }

  // Call parent method
  eat() {
    // Call parent's eat method
    super.eat();
    console.log(`${this.name} enjoys dog food`);
  }
}

const buddy = new Dog("Buddy", "Golden Retriever");

// Call overridden method
buddy.speak(); // Buddy the Golden Retriever barks: Woof!

// Call child's new method
buddy.fetch(); // Buddy is fetching the ball

// Call enhanced parent method
buddy.eat();
// Buddy is eating
// Buddy enjoys dog food

Inheritance Chain Verification

javascript
class Animal {}
class Dog extends Animal {}
class GoldenRetriever extends Dog {}

const dog = new GoldenRetriever();

console.log(dog instanceof GoldenRetriever); // true
console.log(dog instanceof Dog); // true
console.log(dog instanceof Animal); // true
console.log(dog instanceof Object); // true

// Prototype chain relationships
console.log(Object.getPrototypeOf(dog) === GoldenRetriever.prototype); // true
console.log(Object.getPrototypeOf(GoldenRetriever.prototype) === Dog.prototype); // true
console.log(Object.getPrototypeOf(Dog.prototype) === Animal.prototype); // true

Mixin Pattern

javascript
// Define mixin classes
const canFly = {
  fly() {
    console.log(`${this.name} is flying`);
  },
  land() {
    console.log(`${this.name} is landing`);
  },
};

const canSwim = {
  swim() {
    console.log(`${this.name} is swimming`);
  },
  dive() {
    console.log(`${this.name} is diving`);
  },
};

// Use mixins
class Bird {
  constructor(name) {
    this.name = name;
  }
}

// Apply mixins
Object.assign(Bird.prototype, canFly);

class Duck extends Bird {
  constructor(name) {
    super(name);
  }
}

// Duck inherits from Bird and applies canFly
const duck = new Duck("Donald");
duck.fly(); // Donald is flying

// For multiple mixins, can create helper function
function applyMixins(targetClass, ...mixins) {
  mixins.forEach((mixin) => {
    Object.getOwnPropertyNames(mixin).forEach((name) => {
      if (name !== "constructor") {
        targetClass.prototype[name] = mixin[name];
      }
    });
  });
}

// Apply multiple mixins
class AmphibiousCar {
  constructor(name) {
    this.name = name;
  }
}

applyMixins(AmphibiousCar, canSwim, canFly);

const amphiCar = new AmphibiousCar("AmphiCar");
amphiCar.swim(); // AmphiCar is swimming
amphiCar.fly(); // AmphiCar is flying

Static Methods and Static Properties

Static Methods

javascript
class MathHelper {
  // Instance method
  multiply(a, b) {
    return a * b;
  }

  // Static method
  static add(a, b) {
    return a + b;
  }

  static subtract(a, b) {
    return a - b;
  }

  // Static factory method
  static createMultiplier(factor) {
    return new (class {
      multiply(x) {
        return x * factor;
      }
    })();
  }
}

// Call static methods (no instance needed)
console.log(MathHelper.add(5, 3)); // 8
console.log(MathHelper.subtract(10, 3)); // 7

// Create instance
const math = new MathHelper();
console.log(math.multiply(4, 3)); // 12

// Static factory method
const doubleMultiplier = MathHelper.createMultiplier(2);
console.log(doubleMultiplier.multiply(5)); // 10

// Static methods are not inherited by subclasses
class AdvancedMath extends MathHelper {}

console.log(AdvancedMath.add(2, 3)); // 8 (accessed through prototype chain)
console.log(AdvancedMath.subtract(5, 2)); // 3

Static Properties

javascript
class Counter {
  // Static properties (ES2022)
  static count = 0;
  static instances = [];

  constructor(name) {
    this.name = name;
    this.id = Counter.count++;
    Counter.instances.push(this);
  }

  // Static getter
  static get totalCount() {
    return this.count;
  }

  static get allInstances() {
    return [...this.instances];
  }

  // Static method
  static reset() {
    this.count = 0;
    this.instances.length = 0;
  }

  static getInstanceCount() {
    return this.instances.length;
  }
}

const counter1 = new Counter("Counter 1");
const counter2 = new Counter("Counter 2");
const counter3 = new Counter("Counter 3");

console.log(Counter.totalCount); // 3
console.log(Counter.getInstanceCount()); // 3
console.log(Counter.allInstances.length); // 3

Counter.reset();
console.log(Counter.totalCount); // 0
console.log(Counter.getInstanceCount()); // 0

Accessor Properties

Getter and Setter

javascript
class Person {
  constructor(firstName, lastName, age) {
    this._firstName = firstName;
    this._lastName = lastName;
    this._age = age;
  }

  // Getter for fullName
  get fullName() {
    return `${this._firstName} ${this._lastName}`;
  }

  // Setter for fullName
  set fullName(name) {
    const parts = name.split(" ");
    if (parts.length === 2) {
      this._firstName = parts[0];
      this._lastName = parts[1];
    } else {
      throw new Error("Please provide both first and last name");
    }
  }

  // Getter for age
  get age() {
    return this._age;
  }

  // Setter for age with validation
  set age(newAge) {
    if (typeof newAge !== "number" || newAge < 0 || newAge > 150) {
      throw new Error("Age must be a number between 0 and 150");
    }
    this._age = newAge;
  }

  // Read-only property
  get isAdult() {
    return this._age >= 18;
  }
}

const person = new Person("John", "Doe", 25);

console.log(person.fullName); // John Doe
console.log(person.age); // 25
console.log(person.isAdult); // true

// Use setter
person.fullName = "Jane Smith";
console.log(person.fullName); // Jane Smith
console.log(person._firstName); // Jane

// Use validation
person.age = 30; // Normal
console.log(person.age); // 30

try {
  person.age = -5; // Throws error
} catch (error) {
  console.log(error.message); // Age must be a number between 0 and 150
}

Private Fields and Methods

Private Fields (ES2022)

javascript
class BankAccount {
  // Private fields (starting with #)
  #balance;
  #accountNumber;
  #transactions = [];

  constructor(accountNumber, initialBalance = 0) {
    this.#accountNumber = accountNumber;
    this.#balance = initialBalance;
  }

  // Public methods
  deposit(amount) {
    if (amount <= 0) {
      throw new Error("Deposit amount must be positive");
    }
    this.#balance += amount;
    this.#transactions.push({ type: "deposit", amount, date: new Date() });
    return this.#balance;
  }

  withdraw(amount) {
    if (amount <= 0) {
      throw new Error("Withdrawal amount must be positive");
    }
    if (amount > this.#balance) {
      throw new Error("Insufficient funds");
    }
    this.#balance -= amount;
    this.#transactions.push({ type: "withdrawal", amount, date: new Date() });
    return this.#balance;
  }

  // Access private fields
  getBalance() {
    return this.#balance;
  }

  getAccountNumber() {
    return this.#accountNumber;
  }

  getTransactionHistory() {
    return [...this.#transactions]; // Return copy
  }

  // Private method
  #validateAmount(amount) {
    return typeof amount === "number" && amount > 0;
  }

  // Private getter
  get #isOverdrawn() {
    return this.#balance < 0;
  }

  // Use private method
  processTransaction(amount, type) {
    if (!this.#validateAmount(amount)) {
      throw new Error("Invalid amount");
    }

    if (type === "deposit") {
      return this.deposit(amount);
    } else if (type === "withdrawal") {
      return this.withdraw(amount);
    }
  }
}

const account = new BankAccount("123456789", 1000);

// Public access
console.log(account.getBalance()); // 1000
account.deposit(500);
console.log(account.getBalance()); // 1500

// Private fields cannot be accessed from outside
console.log(account.#balance); // SyntaxError: Private field '#balance' must be declared in an enclosing class
console.log(account.#accountNumber); // SyntaxError: Private field '#accountNumber' must be declared in an enclosing class

Traditional Private Pattern (Compatible with Older Versions)

javascript
class TraditionalPrivate {
  constructor(name) {
    // Store private data in weak map
    this._privateData = new Map();
    this._privateData.set(this, {
      name: name,
      secrets: [],
    });
  }

  getName() {
    return this._privateData.get(this).name;
  }

  setName(name) {
    this._privateData.get(this).name = name;
  }

  addSecret(secret) {
    this._privateData.get(this).secrets.push(secret);
  }

  getSecrets() {
    return [...this._privateData.get(this).secrets];
  }
}

// Or use closure pattern
function createPrivateClass() {
  const privateData = new WeakMap();

  return class PrivateExample {
    constructor(name) {
      privateData.set(this, { name, secrets: [] });
    }

    getName() {
      return privateData.get(this).name;
    }

    addSecret(secret) {
      privateData.get(this).secrets.push(secret);
    }
  };
}

const PrivateExample = createPrivateClass();

Advanced Method Usage

Bound Methods

javascript
class Button {
  constructor(text) {
    this.text = text;
    // Ensure this is bound correctly
    this.handleClick = this.handleClick.bind(this);
  }

  handleClick() {
    console.log(`Button "${this.text}" clicked`);
  }

  // Arrow function method (auto-bound)
  handleMouseOver = () => {
    console.log(`Mouse over "${this.text}"`);
  };

  // Regular method (needs binding)
  handleMouseOut() {
    console.log(`Mouse out "${this.text}"`);
  }
}

const button = new Button("Click Me");

// DOM event listening
// document.getElementById('myButton').addEventListener('click', button.handleClick);
// document.getElementById('myButton').addEventListener('mouseover', button.handleMouseOver);
// document.getElementById('myButton').addEventListener('mouseout', button.handleMouseOut.bind(button));

Computed Property Names

javascript
const methodName = "greet";
const propertyName = "fullName";

class Person {
  constructor(firstName, lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
  }

  // Computed method name
  [methodName]() {
    console.log(`Hello, I'm ${this.firstName}`);
  }

  // Computed getter name
  get [propertyName]() {
    return `${this.firstName} ${this.lastName}`;
  }

  // Computed setter name
  set [propertyName](name) {
    const parts = name.split(" ");
    this.firstName = parts[0] || "";
    this.lastName = parts[1] || "";
  }
}

const person = new Person("John", "Doe");
person.greet(); // Hello, I'm John
console.log(person.fullName); // John Doe

person.fullName = "Jane Smith";
console.log(person.fullName); // Jane Smith

Built-in Class Features

new.target

javascript
class Shape {
  constructor(type) {
    this.type = type;

    // Check if called with new
    if (!new.target) {
      throw new Error("Shape must be called with new");
    }

    // new.target points to the constructor being new'd
    console.log("Creating instance of:", new.target.name);
  }
}

class Circle extends Shape {
  constructor(radius) {
    // When calling parent constructor, new.target is Circle
    super("circle");
    this.radius = radius;
  }
}

const shape = new Shape("polygon"); // Creating instance of: Shape
const circle = new Circle(5); // Creating instance of: Circle

// Abstract class pattern
class Animal {
  constructor() {
    if (new.target === Animal) {
      throw new Error("Animal is abstract and cannot be instantiated directly");
    }
  }

  speak() {
    throw new Error("Method must be implemented by subclass");
  }
}

class Dog extends Animal {
  speak() {
    console.log("Woof!");
  }
}

const dog = new Dog();
dog.speak(); // Woof!

// const animal = new Animal(); // Error: Animal is abstract and cannot be instantiated directly

Symbol.species

javascript
class MyArray extends Array {
  static get [Symbol.species]() {
    return Array; // Override species to Array instead of MyArray
  }

  // Custom methods
  first() {
    return this[0];
  }

  last() {
    return this[this.length - 1];
  }
}

const myArray = new MyArray(1, 2, 3, 4, 5);

// map method returns regular Array instead of MyArray
const mapped = myArray.map((x) => x * 2);
console.log(mapped instanceof MyArray); // false
console.log(mapped instanceof Array); // true

// Custom methods still work
console.log(myArray.first()); // 1
console.log(myArray.last()); // 5

Practical Application Examples

Data Model Class

javascript
class UserModel {
  #id;
  #email;
  #name;
  #createdAt;
  #updatedAt;

  constructor({ id, email, name }) {
    this.#id = id || this.#generateId();
    this.#email = this.#validateEmail(email);
    this.#name = name;
    this.#createdAt = new Date();
    this.#updatedAt = new Date();
  }

  // Public accessors
  get id() {
    return this.#id;
  }

  get email() {
    return this.#email;
  }

  get name() {
    return this.#name;
  }

  get createdAt() {
    return this.#createdAt;
  }

  get updatedAt() {
    return this.#updatedAt;
  }

  // Setters
  set email(newEmail) {
    this.#email = this.#validateEmail(newEmail);
    this.#updatedAt = new Date();
  }

  set name(newName) {
    this.#name = newName;
    this.#updatedAt = new Date();
  }

  // Instance methods
  update(data) {
    if (data.email) this.email = data.email;
    if (data.name) this.name = data.name;
    return this;
  }

  toJSON() {
    return {
      id: this.#id,
      email: this.#email,
      name: this.#name,
      createdAt: this.#createdAt,
      updatedAt: this.#updatedAt,
    };
  }

  // Private methods
  #generateId() {
    return Date.now().toString(36) + Math.random().toString(36).substr(2);
  }

  #validateEmail(email) {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(email)) {
      throw new Error("Invalid email format");
    }
    return email;
  }

  // Static methods
  static create(userData) {
    return new UserModel(userData);
  }

  static fromJSON(json) {
    const data = typeof json === "string" ? JSON.parse(json) : json;
    return new UserModel(data);
  }
}

// Usage example
const user = UserModel.create({
  email: "[email protected]",
  name: "John Doe",
});

console.log(user.email); // [email protected]
console.log(user.name); // John Doe

user.update({ name: "John Smith" });
console.log(user.name); // John Smith

const userJSON = user.toJSON();
console.log(userJSON);

const newUser = UserModel.fromJSON(userJSON);
console.log(newUser.email); // [email protected]

State Management Class

javascript
class StateManager {
  #state = {};
  #listeners = new Map();
  #history = [];
  #maxHistorySize = 50;

  constructor(initialState = {}) {
    this.#state = { ...initialState };
    this.#saveToHistory();
  }

  // Get state
  getState(path) {
    if (!path) return { ...this.#state };

    return path.split(".").reduce((obj, key) => obj?.[key], this.#state);
  }

  // Set state
  setState(path, value) {
    const oldValue = this.getState(path);
    const newValue = typeof path === "string" ? value : path;

    if (typeof path === "string") {
      // Update nested property
      this.#updateNestedProperty(path, newValue);
    } else {
      // Update entire state
      this.#state = { ...this.#state, ...newValue };
    }

    this.#saveToHistory();
    this.#notifyListeners(path, newValue, oldValue);

    return this;
  }

  // Subscribe to state changes
  subscribe(path, callback) {
    if (!this.#listeners.has(path)) {
      this.#listeners.set(path, new Set());
    }

    this.#listeners.get(path).add(callback);

    // Return unsubscribe function
    return () => {
      const listeners = this.#listeners.get(path);
      if (listeners) {
        listeners.delete(callback);
        if (listeners.size === 0) {
          this.#listeners.delete(path);
        }
      }
    };
  }

  // Undo
  undo() {
    if (this.#history.length > 1) {
      this.#history.pop(); // Remove current state
      const previousState = this.#history[this.#history.length - 1];
      this.#state = { ...previousState };
      return true;
    }
    return false;
  }

  // Redo
  redo() {
    // Implement redo logic
  }

  // Clear history
  clearHistory() {
    this.#history = [{ ...this.#state }];
    return this;
  }

  // Private methods
  #updateNestedProperty(path, value) {
    const keys = path.split(".");
    const lastKey = keys.pop();
    const target = keys.reduce((obj, key) => {
      if (!obj[key]) obj[key] = {};
      return obj[key];
    }, this.#state);

    target[lastKey] = value;
  }

  #saveToHistory() {
    this.#history.push({ ...this.#state });
    if (this.#history.length > this.#maxHistorySize) {
      this.#history.shift();
    }
  }

  #notifyListeners(path, newValue, oldValue) {
    // Notify specific path listeners
    const specificListeners = this.#listeners.get(path);
    if (specificListeners) {
      specificListeners.forEach((callback) => {
        try {
          callback(newValue, oldValue, path);
        } catch (error) {
          console.error("Listener error:", error);
        }
      });
    }

    // Notify wildcard listeners
    const wildcardListeners = this.#listeners.get("*");
    if (wildcardListeners) {
      wildcardListeners.forEach((callback) => {
        try {
          callback(this.#state, { path, newValue, oldValue });
        } catch (error) {
          console.error("Wildcard listener error:", error);
        }
      });
    }
  }
}

// Usage example
const stateManager = new StateManager({
  user: { name: "John", age: 25 },
  theme: "light",
});

// Subscribe to state changes
const unsubscribeUser = stateManager.subscribe("user", (newUser, oldUser) => {
  console.log("User changed:", { from: oldUser, to: newUser });
});

const unsubscribeTheme = stateManager.subscribe(
  "theme",
  (newTheme, oldTheme) => {
    console.log("Theme changed:", { from: oldTheme, to: newTheme });
  }
);

// Update state
stateManager.setState("user.name", "Jane");
stateManager.setState("theme", "dark");

// Undo
stateManager.undo();

Summary

JavaScript's Class syntax provides a clearer, more intuitive syntax for object-oriented programming while maintaining the underlying prototype-based mechanism:

  • Clear syntax structure: Use class, constructor, extends and other keywords
  • Simplified inheritance mechanism: Easily implement inheritance through extends and super
  • Private field support: Use # syntax to define truly private fields
  • Static methods and properties: Support class-level members
  • Accessor properties: Provide flexible property access through getters and setters
  • Backward compatibility: Still based on prototype inheritance, compatible with existing code

Although Class syntax makes JavaScript look more like traditional object-oriented languages, it's important to understand it's still based on prototype inheritance. Mastering Class syntax while understanding the underlying mechanisms helps us write more modern, maintainable JavaScript code.