Object Oriented Programming (OOP) in JavaScript - Top to Bottom

Object Oriented Programming (OOP) in JavaScript - Top to Bottom

If I give you a nice and clearcut explanation of what OOP is, it would be like this: Object-Oriented Programming (OOP) is a programming paradigm where the focus is on creating reusable objects that model real-world entities or concepts. In OOP, the primary building blocks are objects—self-contained units that encapsulate data (known as properties or attributes) and functions (called methods) that operate on that data.

How OOP is different from Procedural Programming

Unlike OOP, procedural programming focuses on writing functions that work on data separately, following a step-by-step approach. In procedural programming, functions are isolated from the data they use, while in OOP, programs revolve around objects that group both data and methods. This separation in procedural programming can make functions rely on external data, whereas OOP’s encapsulation provides better data protection by keeping them together. As procedural programs grow, maintaining them can become harder, but OOP's structure simplifies managing larger programs by isolating changes within objects.

Key Concepts of OOP

Here is a list of key concepts of OOP in JavaScript with a brief description of what they mean:

  • Classes: Blueprints for creating objects that define their properties and methods.

  • Objects: Instances of classes that represent real-world entities, containing data (properties) and behavior (methods).

  • Inheritance: A mechanism where one class can inherit properties and methods from another, promoting code reuse.

  • Polymorphism: The ability of different objects to respond to the same method call in different ways, often through method overriding.

  • Encapsulation: The bundling of data and methods within an object, restricting direct access to some of the object's components.

  • Abstraction: The process of hiding complex details and showing only the necessary features, simplifying interaction with the object.

This keeps the explanations concise while conveying the key ideas. Each concept is discussed here in this article.

Objects and Prototypes in JavaScript

In JavaScript, objects are one of the core data structures used to represent real-world entities or abstractions. Each object is a collection of properties and methods. JavaScript uses a prototype-based inheritance model, meaning objects can inherit properties and methods from other objects, not from classes as in class-based languages.

Objects in JavaScript

An object in JavaScript is simply a collection of key-value pairs, where the keys are properties (data) and methods (functions).

Here’s how to create a simple object:

const car = {
  brand: 'Toyota',
  model: 'Corolla',
  start: function() {
    console.log(`${this.brand} ${this.model} is starting...`);
  }
};

car.start(); // Output: Toyota Corolla is starting...

Prototypes in JavaScript

Prototypes are the underlying mechanism through which objects inherit features from one another. Every object has a prototype, forming a prototype chain. When an object is created, it inherits properties and methods from its prototype, and this chain continues until a prototype with null is reached.

Example:

const myObject = {
  city: "New York",
  greet() {
    console.log(`Hello from ${this.city}`);
  },
};

Object.getPrototypeOf(myObject); // Object { city: "New York", greet: [Function] }

The Prototype Chain

const myObject = {
  city: "LA",
  greet() {
    console.log(`Greetings from ${this.city}`);
  },
};

myObject.greet(); // Greetings from Madrid

This is an object with a property city and a method greet(). If you type the object's name followed by a period into the console, like myObject., then the console will pop up a list of all the properties available to this object. You'll see that with city and greet, we have a lot of other properties as well! (example taken from here)

What are these extra properties, and where do they come from?

Since prototype is itself an object, it will also have a prototype. This way it creates a chain of prototypes and it’s called a prototype chain. The chain ends when we reach a prototype that has null for its own prototype. When you try to access a property of an object: if the property can’t be found in the object itself, the prototype is searched for the property. If the property still can’t be found, then the prototype’s prototype is searched, and so on until either the property is found, or the end of the chain is reached, in which case undefined is returned.

So when we call myObject.toString(), the browser:

  • looks for toString in myObject

  • can’t find it there, so looks in the prototype object of myObject for toString

  • finds it there, and calls it.

Now, how could we find out the prototype of a certain object? we can use the function Object.getPrototypeOf()

Object.getPrototypeOf(myObject)

What happens if you define a property in an object, when a property with the same name is defined in the object’s prototype?

When you add a property to an object that has the same name as a property in its prototype, the new property in the object hides or replaces the one from the prototype, but only for that specific object. The property in the object will be used instead of the one in the prototype, and JavaScript won’t look up the chain to the prototype anymore.

Setting a prototype Using Object.create

Object.create() is a powerful method in JavaScript that allows you to create a new object with a specific prototype. It directly links the newly created object to another object, allowing it to inherit properties and methods from that object.

How It Works:
When you use Object.create(), you create a new object and set its prototype to an existing object. This means that the new object can access properties and methods of the prototype object through prototype inheritance.

Example:

const animal = {
  makeSound: function() {
    console.log(this.sound);
  }
};

const dog = Object.create(animal); // dog inherits from animal
dog.sound = 'Bark';

dog.makeSound(); // Output: Bark

Example of Adding Methods to the Prototype:

const car = {
  start: function() {
    console.log(`${this.model} is starting...`);
  }
};

const myCar = Object.create(car);
myCar.model = 'Tesla Model S';
myCar.start(); // Output: Tesla Model S is starting...

Constructor Functions

A constructor function is a special type of function used to create and initialize objects in JavaScript. Before classes were introduced in ES6, constructor functions were the primary way to create multiple objects with the same structure and behavior.

Constructor functions help set up the properties and methods for objects in a way that makes them reusable and shareable. They work by:

  1. Defining the initial properties for the objects.

  2. Being called with the new keyword to create a new object instance.

How to Define a Constructor Function

A constructor function is just a regular function, but by convention, its name starts with a capital letter to distinguish it from normal functions.

function Person(name, age) {
  // Assign properties to the object being created
  this.name = name;
  this.age = age;

  // You can also define methods here
  this.greet = function() {
    console.log(`Hello, my name is ${this.name}`);
  };
}

One thing to remember, you cannot define a constructor function with an arrow function in JavaScript. Arrow functions are not suitable for use as constructor functions because they do not have their own this context or prototype.

How to Use a Constructor Function

To create an object using a constructor function, you need to use the new keyword:

const person1 = new Person('Alice', 25);
const person2 = new Person('Bob', 30);

person1.greet(); // Output: Hello, my name is Alice
person2.greet(); // Output: Hello, my name is Bob

What Happens Behind the Scenes?

When you use the new keyword with a constructor function, JavaScript performs several steps:

  1. Creates a new object.

  2. Sets the this value in the constructor function to the new object.

  3. Adds properties and methods to the new object via this.

  4. Returns the new object at the end, even if return is not explicitly used.

Prototype Inheritance with Constructor Functions

A major benefit of using constructor functions is that they allow objects to share methods through prototypes. Instead of defining methods inside the constructor (which would create a new function for every instance), you can define methods on the prototype to share them among all instances.

function Animal(type) {
  this.type = type;
}

Animal.prototype.speak = function() {
  console.log(`This ${this.type} is making a sound.`);
};

const dog = new Animal('dog');
const cat = new Animal('cat');

dog.speak(); // Output: This dog is making a sound.
cat.speak(); // Output: This cat is making a sound.

In this example, speak is added to the Animal.prototype, so all instances of Animal (like dog and cat) share the same speak method, which is memory-efficient.

Even though ES6 classes provide a more modern approach, understanding constructor functions is crucial because it helps you understand the internal mechanisms of JavaScript and how classes actually work under the hood.

Classes in JavaScript

Let's dive into classes in JavaScript. ES6 introduced classes to provide a cleaner, more structured way to implement object-oriented programming (OOP) principles, making code more readable and easier to manage.

What Are Classes in JavaScript?

A class in JavaScript is a blueprint for creating objects with similar properties and methods. Classes make it easier to create multiple objects with shared functionality, while allowing each object to have its own unique data.

JavaScript classes, while providing a more familiar OOP syntax, still rely on prototypes under the hood. This means they are syntactic sugar over JavaScript's existing prototype-based inheritance.

Here's the basic structure of a JavaScript class:

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

  // Method
  greet() {
    console.log(`Hello, my name is ${this.name} and I am ${this.age} years old.`);
  }
}

// Creating objects (instances) from the class
const person1 = new Person('Alice', 25);
const person2 = new Person('Bob', 30);

person1.greet(); // Output: Hello, my name is Alice and I am 25 years old.
person2.greet(); // Output: Hello, my name is Bob and I am 30 years old.

Key Concepts in Classes

  1. Constructor Method: The constructor is a special method that gets called when a new object is created from a class. It is used to initialize the object’s properties.

     class Animal {
       constructor(type, sound) {
         this.type = type;
         this.sound = sound;
       }
     }
    
     const dog = new Animal('Dog', 'Bark');
     console.log(dog.type); // Output: Dog
    
  2. Methods: Functions that are defined within a class are called methods. These methods can be called on objects created from the class. In the Person class above, greet() is a method that each instance of Person can use.

  3. Inheritance: A key concept in OOP, inheritance allows one class (child) to inherit properties and methods from another class (parent). You use the extends keyword to create a subclass that inherits from a parent class.

     class Vehicle {
       constructor(make, model) {
         this.make = make;
         this.model = model;
       }
    
       start() {
         console.log(`${this.make} ${this.model} is starting.`);
       }
     }
    
     class Car extends Vehicle {
       constructor(make, model, doors) {
         super(make, model); // Calls the constructor of the parent class
         this.doors = doors;
       }
    
       honk() {
         console.log(`${this.make} ${this.model} is honking.`);
       }
     }
    
     const myCar = new Car('Toyota', 'Corolla', 4);
     myCar.start(); // Output: Toyota Corolla is starting.
     myCar.honk();  // Output: Toyota Corolla is honking.
    

    super(): The super() function is used in child classes to call the parent class's constructor and access its properties and methods.

  4. Getters and Setters: Getters allow you to access properties, while setters allow you to define or update properties in a controlled way. Getters and setters look like properties when accessed.

     class Circle {
       constructor(radius) {
         this._radius = radius;
       }
    
       // Getter
       get radius() {
         return this._radius;
       }
    
       // Setter
       set radius(newRadius) {
         if (newRadius > 0) {
           this._radius = newRadius;
         } else {
           console.log('Radius must be a positive value.');
         }
       }
    
       get area() {
         return Math.PI * this._radius * this._radius;
       }
     }
    
     const circle = new Circle(5);
     console.log(circle.area); // Output: 78.53981633974483
     circle.radius = 10;
     console.log(circle.area); // Output: 314.1592653589793
    
  5. Static Methods: Static methods are methods that belong to the class itself rather than instances of the class. They are useful for utility functions or functions that should not depend on individual objects.

     class MathHelper {
       static add(a, b) {
         return a + b;
       }
     }
    
     console.log(MathHelper.add(5, 3)); // Output: 8
    

    Static methods are called on the class itself, not on instances of the class.

  6. Private Fields: Private fields are marked with a # symbol and can only be accessed or modified inside the class where they are defined.

     class BankAccount {
       #balance = 0;
    
       deposit(amount) {
         this.#balance += amount;
       }
    
       getBalance() {
         return this.#balance;
       }
     }
    
     const account = new BankAccount();
     account.deposit(100);
     console.log(account.getBalance()); // Output: 100
     // console.log(account.#balance); // Error: Private field
    

    This ensures that certain properties or methods can only be accessed within the class, not from outside.

Classes in JavaScript offer a structured way to create objects, manage inheritance, and organize code efficiently. They simplify the implementation of OOP principles while preserving JavaScript’s flexibility.

Inheritance

Inheritance is a core concept in Object-Oriented Programming (OOP) that allows one class or object to inherit properties and methods from another. JavaScript, being prototype-based, historically used prototypes for inheritance. With the introduction of ES6 classes, JavaScript now provides a cleaner, more familiar way to implement inheritance, although it still uses prototypes under the hood.

Prototype-based Inheritance

Even in ES6, you can still create objects and set up inheritance directly using Object.create() and Object.setPrototypeOf(). If you read through the article so far, you already know how inheritance mechanism works using Object.create() method. Now, let’s look into Setting Prototypes with Object.setPrototypeOf() method.

Object.setPrototypeOf(): This method allows you to set or change the prototype of an object explicitly. Unlike Object.create(), Object.setPrototypeOf() sets or changes the prototype of an existing object.

// Parent object
const animal = {
  type: 'animal',
  speak() {
    console.log(`${this.type} makes a sound.`);
  }
};

// Child object
const dog = {
  type: 'dog',
  bark() {
    console.log(`${this.type} says Woof!`);
  }
};

// Set the prototype of 'dog' to 'animal'
Object.setPrototypeOf(dog, animal);

// Test the inheritance
dog.speak(); // Output: dog makes a sound.
dog.bark();  // Output: dog says Woof!

Here, Object.setPrototypeOf(dog, animal) explicitly sets the animal object as the prototype of dog, allowing dog to inherit from animal.

Class-based Inheritance (ES6)

With ES6, JavaScript introduced class syntax, which is a more intuitive way to work with inheritance. This syntax mimics the class-based approach seen in other OOP languages like Java or Python, though it's still built on prototypes.

Example of Class-based Inheritance:

// Parent class
class Animal {
  constructor(type) {
    this.type = type;
  }

  speak() {
    console.log(`${this.type} makes a sound.`);
  }
}

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

  bark() {
    console.log(`${this.name} says Woof!`);
  }
}

// Create a Dog object
const myDog = new Dog('Rex', 'Dog');
myDog.speak(); // Output: Dog makes a sound.
myDog.bark();  // Output: Rex says Woof!

class Dog extends Animal: The extends keyword is used to create a subclass (child) that inherits from a superclass (parent).

super(type): The super keyword is used to call the constructor of the parent class. This is required before using this in the child class constructor.

Class-based inheritance provides a cleaner and more readable approach while still leveraging prototypes.

Encapsulation in JavaScript

Encapsulation is the practice of bundling data (properties) and methods (functions) that operate on that data within a single unit (like an object or class) and restricting direct access to some of the object's components. This ensures that an object’s internal state is protected, and its data can only be modified in controlled ways through methods.

In JavaScript, encapsulation is traditionally achieved through:

  • Public properties/methods: Accessible anywhere.

  • Private properties/methods: Not accessible outside the object.

  • Protected properties/methods: JavaScript doesn’t have a built-in protected keyword like some other languages, but we can simulate it through convention or by using closures.

Public Properties and Methods: In JavaScript, properties and methods defined directly on the object or class are public by default. They can be accessed and modified from anywhere.

class Person {
  constructor(name) {
    this.name = name;  // Public property
  }

  greet() {
    console.log(`Hello, my name is ${this.name}.`);  // Public method
  }
}

const john = new Person('John');
console.log(john.name);  // Accessing the public property
john.greet();  // Accessing the public method

Here, name and greet() are public. They can be accessed and modified directly from outside the class.

Private Fields (Introduced with #): Private fields, introduced in ES2022, are denoted using a # symbol. These fields are not accessible from outside the class and are not visible in the object even when inspected.

class Person {
  #age;  // Private field

  constructor(name, age) {
    this.name = name;
    this.#age = age;  // Private field access within the class
  }

  getAge() {
    return this.#age;  // Private field can only be accessed via methods inside the class
  }
}

const john = new Person('John', 30);
console.log(john.name);  // Public property: 'John'
console.log(john.getAge());  // Accessing private field via method: 30
console.log(john.#age);  // Error: Private field '#age' must be declared in an enclosing class

Private fields can only be accessed or modified within the class where they are declared. They offer true encapsulation, as there's no way to access or alter them outside the class.

Simulating Private Properties with Closures: Before # was introduced, private properties were simulated using closures. This approach hides properties by creating them inside the constructor function but not attaching them to this.

function Person(name, age) {
  let _age = age;  // Private property via closure

  this.name = name;  // Public property

  this.getAge = function() {  // Public method to access the private property
    return _age;
  };

  this.setAge = function(newAge) {  // Public method to modify the private property
    _age = newAge;
  };
}

const john = new Person('John', 30);
console.log(john.name);  // Public property: 'John'
console.log(john.getAge());  // Accessing private property via method: 30
john.setAge(35);  // Modifying private property via method
console.log(john.getAge());  // Updated private property: 35

Protected Properties: JavaScript doesn’t natively support protected properties, but a common convention is to use an underscore (_) prefix to indicate that a property should be treated as protected, meaning it's intended for internal use but can technically be accessed.

class Person {
  _age;  // Conventionally treated as protected (no real enforcement)

  constructor(name, age) {
    this.name = name;
    this._age = age;
  }

  getAge() {
    return this._age;  // Accessing protected property inside class
  }
}

const john = new Person('John', 30);
console.log(john._age);  // Accessible, but conventionally treated as "protected"

This isn’t a true restriction but rather a signal to other developers to not access _age directly.

Polymorphism in JavaScript

Polymorphism in JavaScript refers to the ability of different objects to respond to the same function or method in their own unique way. This means that a single method can have different behaviors depending on the object it is acting upon.

Polymorphism can be achieved through:

  • Method overriding: Where a subclass provides its own implementation of a method defined in the parent class.

  • Method overloading (not native in JavaScript but can be simulated): Where the same method name can have different signatures (like in some other languages).

Method overriding

Method overriding in JavaScript occurs when a subclass provides a specific implementation of a method that is already defined in its parent class. The overriding method in the subclass has the same name and parameters as the parent class method, but the subclass defines how it behaves differently.

class Vehicle {
  move(speed) {
    console.log(`The vehicle is moving at ${speed} km/h`);
  }
}

class Car extends Vehicle {
  move(speed) {  // Overriding the parent method and using the parameter
    console.log(`The car is driving at ${speed} km/h`);
  }
}

class Bicycle extends Vehicle {
  move(speed) {  // Overriding the parent method and using the parameter
    console.log(`The bicycle is pedaling at ${speed} km/h`);
  }
}

const myCar = new Car();
const myBicycle = new Bicycle();

myCar.move(80);        // Output: The car is driving at 80 km/h
myBicycle.move(25);    // Output: The bicycle is pedaling at 25 km/h

Parent class (Vehicle): Defines a move(speed) method that accepts a speed parameter.

Subclass (Car and Bicycle): Override the move(speed) method, also taking the same speed parameter.

The parameter speed is passed when calling the move() method on an object of Car or Bicycle, and each subclass handles the method differently.

Method overloading

Method overloading is a concept where multiple methods share the same name but have different parameters (signatures). In languages like Java or C++, method overloading allows defining multiple versions of a method based on the number or types of parameters. However, JavaScript does not natively support method overloading in the same way.

Simulating Method Overloading in JavaScript

Even though JavaScript doesn't have built-in support for method overloading, you can simulate it by checking the number or types of arguments within a single method and altering the behavior accordingly.

Example:

class Calculator {
  calculate(a, b) {
    if (typeof b === 'undefined') {
      return a * a;  // If only one argument is passed, return square of the number
    } else {
      return a + b;  // If two arguments are passed, return their sum
    }
  }
}

const calc = new Calculator();

console.log(calc.calculate(5));     // Output: 25 (Square of 5)
console.log(calc.calculate(5, 10)); // Output: 15 (Sum of 5 and 10)

Breaking down this example:

  • In this calculate() method, JavaScript handles different numbers of arguments by checking if the second argument b is undefined.

  • If only one argument (a) is passed, the method returns the square of a.

  • If two arguments (a and b) are passed, it returns their sum.

Another Example with Type Checking:

class Display {
  show(value) {
    if (typeof value === 'string') {
      console.log(`Showing text: ${value}`);
    } else if (typeof value === 'number') {
      console.log(`Showing number: ${value}`);
    }
  }
}

const display = new Display();

display.show("Hello!");  // Output: Showing text: Hello!
display.show(42);        // Output: Showing number: 42

Breaking down this example:

  • The show() method checks the type of the argument (value).

  • If the argument is a string, it displays a message for text.

  • If the argument is a number, it shows a different message for numbers.

Abstraction

Abstraction is a key concept in object-oriented programming that focuses on hiding the implementation details of an object and exposing only the essential features. It allows you to interact with an object through a simplified interface without needing to understand how the object internally works. In languages like Java or C#, abstraction is commonly achieved through interfaces and abstract classes, but JavaScript doesn’t have native interfaces or abstract classes.

However, abstraction can still be achieved in JavaScript using other methods like:

  • Using functions and classes to hide complexity: By designing classes or functions that expose only the necessary methods while keeping the internal details private or hidden, you can achieve abstraction.

  • Private methods and properties: JavaScript provides a way to create private methods and properties, especially with the introduction of private fields using the # symbol (ES2020), which helps in implementing abstraction.

Example 1: Abstraction with Public and Private Methods (ES6+)

class Car {
  // Private field using #
  #startEngine() {
    console.log("Engine started");
  }

  // Public method
  drive() {
    this.#startEngine();
    console.log("The car is driving");
  }
}

const myCar = new Car();
myCar.drive();   // Output: Engine started
                 //         The car is driving

// myCar.#startEngine();  // Error: Private method can't be accessed outside the class

Explanation: The Car class has a private method #startEngine() that cannot be accessed directly from outside the class. The drive() method, which is public, handles all the complexities of starting the engine before driving, keeping the internal logic hidden. This is an example of abstraction, as the user doesn't need to worry about how the engine is started; they only call the drive() method.

Example 2: Abstraction Using Closure (Pre-ES6)

Before ES6 introduced private fields, abstraction was often achieved using closures to hide internal details.

function Car() {
  let fuelLevel = 100; // Private variable

  function startEngine() {  // Private method
    console.log("Engine started");
  }

  // Public method
  this.drive = function() {
    startEngine();
    console.log("The car is driving");
  };
}

const myCar = new Car();
myCar.drive();   // Output: Engine started
                 //         The car is driving

// myCar.startEngine();  // Error: startEngine is not a function

Explanation: In this version, we use a constructor function and closures to achieve abstraction. The fuelLevel variable and the startEngine() function are not accessible from outside the Car function. Only the public drive() method is exposed to the user.

Conclusion

Object-Oriented Programming (OOP) in JavaScript provides a powerful way to structure code, making it more modular, reusable, and easier to maintain. By leveraging key concepts like encapsulation, inheritance, polymorphism, and abstraction, developers can create complex systems that are flexible and scalable. While JavaScript’s prototype-based inheritance differs from traditional class-based languages, ES6 introduced classes to provide a more intuitive way to implement OOP principles. Understanding how OOP works in JavaScript, both in its pre-ES6 and modern forms, equips developers with the tools they need to write better, more efficient code for real-world applications.