coderain guide

The Basics of Object-Oriented JavaScript

JavaScript, often hailed as the "language of the web," is a versatile programming language that supports multiple paradigms, including procedural, functional, and **object-oriented programming (OOP)**. While many developers associate OOP with class-based languages like Java or C++, JavaScript takes a unique approach: it uses a **prototype-based** OOP model. This means instead of classes, JavaScript relies on objects and their prototypes to enable inheritance and reuse. Whether you’re building a simple web app or a complex frontend framework, understanding OOP in JavaScript is critical. It helps organize code, promote reusability, and model real-world entities more naturally. In this blog, we’ll break down the fundamentals of object-oriented JavaScript, from core OOP concepts to practical examples. By the end, you’ll grasp how JavaScript’s prototype system works and how to leverage it effectively.

Table of Contents

  1. What is Object-Oriented Programming (OOP)?
  2. Core Concepts of OOP in JavaScript
  3. Objects in JavaScript
  4. Constructors and Prototypes
  5. ES6 Classes: Syntactic Sugar
  6. Inheritance in JavaScript
  7. Practical Example: A Shape Hierarchy
  8. Common Pitfalls and Best Practices
  9. Conclusion
  10. References

What is Object-Oriented Programming (OOP)?

Object-Oriented Programming (OOP) is a programming paradigm centered around objects rather than functions or logic. An object is a collection of data (properties) and behavior (methods) that act on that data. OOP aims to model real-world entities (e.g., a user, a car, a bank account) by bundling their attributes and actions into reusable, modular units.

Unlike class-based OOP languages (e.g., Java, Python), JavaScript uses a prototype-based model. This means there are no “classes” in the traditional sense (though ES6 introduced class syntax as syntactic sugar). Instead, objects inherit directly from other objects via a “prototype” chain.

Core Concepts of OOP in JavaScript

Encapsulation

Encapsulation is the practice of bundling data (properties) and methods that operate on that data into a single unit (an object), while restricting access to some of the object’s components. This hides internal state and exposes only necessary functionality, reducing complexity and preventing unintended side effects.

In JavaScript, encapsulation can be achieved using:

  • Object literals (grouping properties/methods).
  • Closures (to hide private variables).
  • ES6 Symbols or WeakMaps (to simulate private properties).

Example with closures (private data):

function createBankAccount(initialBalance) {
  let balance = initialBalance; // Private variable (only accessible via methods)

  return {
    deposit(amount) {
      balance += amount;
      return balance;
    },
    withdraw(amount) {
      if (amount > balance) throw new Error("Insufficient funds");
      balance -= amount;
      return balance;
    },
    getBalance() {
      return balance;
    }
  };
}

const account = createBankAccount(100);
account.deposit(50); // 150
account.getBalance(); // 150
account.balance; // undefined (private!)

Abstraction

Abstraction focuses on hiding unnecessary implementation details and exposing only essential features. It simplifies complex systems by providing a high-level interface.

For example, a Car object might expose startEngine() and drive() methods without revealing how the engine ignites or the transmission works.

Example:

class Car {
  constructor(make, model) {
    this.make = make;
    this.model = model;
    this.isEngineRunning = false;
  }

  startEngine() {
    this.isEngineRunning = true;
    console.log(`${this.make} ${this.model} engine started.`);
  }

  drive() {
    if (!this.isEngineRunning) {
      this.startEngine(); // Abstracts the need to call startEngine() first
    }
    console.log(`${this.make} ${this.model} is driving.`);
  }
}

const myCar = new Car("Tesla", "Model 3");
myCar.drive(); // Output: "Tesla Model 3 engine started." followed by "Tesla Model 3 is driving."

Inheritance

Inheritance allows objects to reuse properties and methods from other objects, promoting code reuse and establishing relationships between entities (e.g., a Dog inherits from an Animal).

In JavaScript, inheritance is实现 via the prototype chain. Every object has a [[Prototype]] (or __proto__), which references another object. When accessing a property/method, JavaScript first checks the object itself; if not found, it traverses the prototype chain until it finds the property or reaches null.

Polymorphism

Polymorphism means “many forms.” It allows objects of different types to be treated uniformly through a common interface. In practice, this often involves overriding methods in child objects to provide type-specific behavior.

Example:

// Parent object
const Animal = {
  speak() {
    return "Generic animal sound";
  }
};

// Child objects inheriting from Animal
const Dog = Object.create(Animal);
Dog.speak = () => "Woof!"; // Override speak()

const Cat = Object.create(Animal);
Cat.speak = () => "Meow!"; // Override speak()

// Polymorphic function
function makeAnimalSpeak(animal) {
  console.log(animal.speak());
}

makeAnimalSpeak(Dog); // "Woof!"
makeAnimalSpeak(Cat); // "Meow!"

Objects in JavaScript

Creating Objects

JavaScript objects can be created in several ways:

1. Object Literal (Most Common)

const person = {
  name: "Alice",
  age: 30,
  greet() {
    return `Hello, I'm ${this.name}`; // 'this' refers to the object
  }
};

2. Object Constructor

const car = new Object();
car.make = "Toyota";
car.model = "Camry";
car.year = 2023;

3. Object.create()

Creates a new object with a specified prototype.

const animal = { isAlive: true };
const dog = Object.create(animal); // dog's prototype is animal
dog.breed = "Labrador";
console.log(dog.isAlive); // true (inherited from animal)

Object Properties and Methods

  • Properties: Key-value pairs storing data (e.g., name: "Alice").
  • Methods: Functions stored as properties (e.g., greet() { ... }).

Property Types

  • Data properties: Store values (default).
  • Accessor properties: Use get/set methods to compute values dynamically.

Enumerability and Writable Flags

Properties have attributes like enumerable (whether they appear in loops) and writable (whether they can be modified). Use Object.defineProperty() to configure them:

const person = {};
Object.defineProperty(person, "name", {
  value: "Bob",
  writable: false, // Cannot be changed
  enumerable: true // Appears in loops
});

person.name = "Alice"; // Fails (in strict mode, throws error)
console.log(person.name); // "Bob"

Accessor Properties (Getters and Setters)

Accessor properties use get and set to control reading/writing values, enabling validation or computed properties.

Example:

const rectangle = {
  width: 10,
  height: 20,
  get area() {
    return this.width * this.height; // Computed when accessed
  },
  set dimensions(newDimensions) {
    [this.width, this.height] = newDimensions; // Validate input here
  }
};

console.log(rectangle.area); // 200
rectangle.dimensions = [15, 25];
console.log(rectangle.area); // 375

Constructors and Prototypes

Before ES6, JavaScript used function constructors to create reusable object templates. When a function is called with new, it becomes a constructor, returning a new object with its own properties and methods inherited from the constructor’s prototype.

Function Constructors

A constructor function initializes object properties. By convention, constructor names are capitalized.

Example:

function Person(name, age) {
  this.name = name; // 'this' refers to the new object being created
  this.age = age;
}

// Add a method to the constructor's prototype (shared by all instances)
Person.prototype.greet = function() {
  return `Hello, I'm ${this.name}, ${this.age} years old.`;
};

// Create instances with 'new'
const alice = new Person("Alice", 30);
const bob = new Person("Bob", 25);

console.log(alice.greet()); // "Hello, I'm Alice, 30 years old."
console.log(bob.greet()); // "Hello, I'm Bob, 25 years old."

The Prototype Object

Every function has a prototype property (an object) that is inherited by all instances created with new. This allows methods to be shared across instances, saving memory.

  • Person.prototype is the prototype object for alice and bob.
  • Instances access prototype methods via the [[Prototype]] chain (exposed as __proto__ in browsers).
console.log(alice.__proto__ === Person.prototype); // true
console.log(Person.prototype.__proto__ === Object.prototype); // true (inherited from Object)

The Prototype Chain

When accessing a property/method on an object, JavaScript first checks the object itself. If not found, it traverses the prototype chain (via __proto__) until it finds the property or reaches null.

Example:

console.log(alice.toString()); // "[object Object]" (inherited from Object.prototype)

ES6 Classes: Syntactic Sugar

ES6 introduced class syntax, which simplifies working with prototypes. Despite looking like class-based OOP, it’s still prototype-based under the hood.

Class Declaration

class Person {
  // Constructor initializes properties
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }

  // Instance method (added to prototype)
  greet() {
    return `Hello, I'm ${this.name}`;
  }

  // Static method (attached to the class, not instances)
  static isAdult(age) {
    return age >= 18;
  }
}

const charlie = new Person("Charlie", 22);
console.log(charlie.greet()); // "Hello, I'm Charlie"
console.log(Person.isAdult(22)); // true

Constructor Method

The constructor is called when creating a new instance with new. It initializes instance properties. If omitted, a default constructor is used.

Instance and Static Methods

  • Instance methods: Defined inside the class (e.g., greet()) and inherited by instances via the prototype.
  • Static methods: Defined with static (e.g., isAdult()) and belong to the class itself, not instances.

Inheritance in JavaScript

Prototype-Based Inheritance

Before ES6, inheritance was实现 by manually setting an object’s prototype.

Example:

// Parent constructor
function Animal(name) {
  this.name = name;
}

Animal.prototype.speak = function() {
  return `${this.name} makes a sound.`;
};

// Child constructor
function Dog(name, breed) {
  Animal.call(this, name); // Call parent constructor (super)
  this.breed = breed;
}

// Set Dog's prototype to inherit from Animal.prototype
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog; // Fix constructor reference

// Override speak()
Dog.prototype.speak = function() {
  return `${this.name} barks.`;
};

const buddy = new Dog("Buddy", "Golden Retriever");
console.log(buddy.speak()); // "Buddy barks."

ES6 Class Inheritance with extends and super

ES6 simplifies inheritance with extends (to inherit from a parent class) and super (to call the parent constructor/methods).

Example:

class Animal {
  constructor(name) {
    this.name = name;
  }

  speak() {
    return `${this.name} makes a sound.`;
  }
}

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

  speak() {
    return `${this.name} barks.`; // Override parent method
  }
}

const max = new Dog("Max", "Poodle");
console.log(max.speak()); // "Max barks."
console.log(max instanceof Animal); // true (inherited)

Practical Example: A Shape Hierarchy

Let’s build a simple hierarchy to demonstrate OOP concepts:

class Shape {
  constructor(color) {
    this.color = color;
  }

  // Abstract method (to be overridden)
  getArea() {
    throw new Error("Subclasses must implement getArea()");
  }

  // Shared method
  getColor() {
    return this.color;
  }
}

class Circle extends Shape {
  constructor(color, radius) {
    super(color);
    this.radius = radius;
  }

  getArea() {
    return Math.PI * this.radius ** 2; // Override getArea()
  }
}

class Square extends Shape {
  constructor(color, sideLength) {
    super(color);
    this.sideLength = sideLength;
  }

  getArea() {
    return this.sideLength ** 2; // Override getArea()
  }
}

// Usage
const redCircle = new Circle("red", 5);
console.log(redCircle.getArea()); // ~78.54
console.log(redCircle.getColor()); // "red"

const blueSquare = new Square("blue", 4);
console.log(blueSquare.getArea()); // 16

Common Pitfalls and Best Practices

  1. this Binding Issues:
    this in methods refers to the instance when called on an object. Avoid losing context (e.g., when passing methods as callbacks). Use arrow functions or bind().

  2. Forgetting new with Constructors:
    Omitting new when calling a constructor leads to this being the global object (or undefined in strict mode).

  3. Prototype Chain Misunderstandings:
    Inherited properties are not copied to instances—they’re accessed via the chain. Modifying a prototype affects all instances.

  4. Private Properties:
    ES6+ supports private fields with # (e.g., #balance), but they’re truly private and not accessible via the prototype.

Conclusion

Object-Oriented JavaScript revolves around objects, prototypes, and inheritance. While ES6 class syntax simplifies the syntax, JavaScript remains prototype-based at its core. By mastering encapsulation, abstraction, inheritance, and polymorphism, you can write modular, reusable, and maintainable code.

Start small: build simple hierarchies (e.g., users, products) and experiment with prototypes. The more you practice, the more intuitive JavaScript’s OOP model will become!

References