Back to top

Object-Oriented Programming in JavaScript Explained

Published: Jan 4, 2024


Object-oriented programming (OOP) is an important programming paradigm that models real-world concepts. It structures code to focus on objects that contain related data and functionality. If you’re coming from a procedural programming background, OOP may take some getting used to. However, it offers many advantages for building and maintaining complex applications in JavaScript.


What is Object-Oriented Programming?


At a high-level, object-oriented programming organizes code into object definitions called classes. These classes act as blueprints to create individual instances called objects.


For example, you can have a Car class that defines the generic properties and functions associated with cars. You can then create many specific car1, car2, etc. object instances from that class blueprint.


OOP revolves around several main concepts:


Encapsulation


This refers to bundling related properties (data) and methods (functions) within classes. The internal details are then hidden or encapsulated from external code. Objects only expose a public API to interact with them in limited ways.


This offers abstraction and reduces complexity for users of that object.


Inheritance


Classes can inherit commonly used state and behavior from other classes.


For example, a Truck class can inherit the generic properties of Car, with some added specifics of its own. This allows hierarchical relationships for specialized concepts derived from general ones.


Abstraction


This involves exposing only essential details and behavior while hiding unnecessary complexity. For example, a accelerate() method abstracts away complex engine and traction mechanisms.


Polymorphism


Objects derived from the same parent class can be used interchangeably even if they behave differently based on type.


For example, Truck and Car offer their own accelerate() methods handling their different engines under the hood. But they can be called polymorphically through their shared parent class interface.


OOP in JavaScript allows logically organizing code and modeling real-world relationships. We’ll explore the various object-oriented concepts in depth through the rest of this post.


Understanding Objects in JavaScript


Before jumping into object-oriented programming, let’s recap how to work with objects in JavaScript.


Objects in JavaScript are collections of keyed values similar to dictionaries or maps in other languages. The keys are equivalent to properties that reference values.


const person = {
    name: "John",
    age: 30 
};

This person object has a name property storing the string "John" and an age property storing the number 30.


We interact with these properties using dot notation or square bracket syntax:


person.name; // "John"
person["age"]; // 30  

The values in an object can include primitive data types as well as nested objects and functions known as methods.


Methods allow objects to have associated functionality:


const person = {
    name: "John",
    greet() {
        console.log("Hello!"); 
    }
}

person.greet(); // Logs "Hello!"

This covers the basics of working with plain JavaScript objects. Next, we’ll explore ways of generating object instances programmatically.


Constructor Functions


Constructor functions serve as class blueprints to create objects through the new keyword .


For example:


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

const john = new Person("John", 30); 

This defines a Person() constructor that initializes name and age properties on a new object when called with new.


We call the Person() constructor with new to instantiate a specific john person object with those properties.


Constructor functions implement a standard OOP pattern to define state through properties and behavior through methods. This allows constructing reusable components modeled after real concepts.


Prototypes and Inheritance


In JavaScript, inheritance works through prototypal inheritance instead of classes. Essentially, objects have a linked prototype property pointing to another object. When a property isn’t found on an object, the JavaScript engine traverses up this prototype chain to inherit from parent objects.


Let’s link prototypes to enable inheritance between Person and Student:


function Person(name) {
    this.name = name;
}

// Add greet method to Person prototype
Person.prototype.greet = function() {
    console.log(`Hello, my name is ${this.name}!`);
}

function Student(name, level) {
    // Call parent constructor
    Person.call(this, name);

    this.level = level;
}

// Link prototypes
Student.prototype = Object.create(Person.prototype);

// Add introduce method
Student.prototype.introduce = function() {
    console.log(`I'm a ${this.level} student.`);  
}

const omar = new Student("Omar", "PhD");

This chains the prototypes so Student can access methods from Person via inheritance.


For example, we can call:


omar.greet(); // Inherited method
omar.introduce(); // Own method 

This prototype linkage allows hierarchy between blueprints, code reuse through inheritance, and polymorphism.


The call() Method


Earlier, we used Person.call() inside our Student constructor. So what does .call() actually do?


The .call() method can invoke a function and set the meaning of this in the function execution context.


Essentially, all JavaScript functions are actually methods that carry the .call() and other built-in methods. For constructor functions, we leverage .call() to execute the parent constructor but set the meaning of this to the new child object.


Without .call():


function Person(name) {
    // 'this' is the global object 
    this.name = name; 
} 

function Student(name) {
    Person(name); // Just executes function without any 'this'
}

const omar = new Student("Omar"); 
console.log(omar.name); // Undefined

The Person constructor sets its name on the global object instead of Student since it loses its execution context.


With .call():


function Student(name) {
    // Set 'this' context to the new student 
    Person.call(this, name);  
}  

const omar = new Student("Omar");
console.log(omar.name); // "Omar"

Now Person() sets name on the intended Student instance. Call allows “borrowing” functions while setting a specific context preserved by the this keyword. All functions have access to call() and apply() for this purpose.


ES6 Classes


JavaScript ES6 introduced cleaner syntax for prototypes and inheritance through classes.


Classes serve as syntactic sugar to help developers from class-based backgrounds transition to JS.


Here is how we can convert our Person and Student code to use new ES6 classes:


class Person {
    constructor(name) {
        this.name = name;
    }
    greet() {
        console.log(`Hello, my name is ${this.name}!`);  
    }
}

class Student extends Person {
    constructor(name, level) { 
        // Call parent constructor
        super(name);  
        this.level = level;
    }
    introduce() {
        console.log(`I'm a ${this.level} student.`);
    }
}

const omar = new Student("Omar", "PhD");  

This class-based syntax offers a cleaner way to:


  • Declare classes with an empty class keyword
  • Define constructors to initialize state
  • Use extends for inheritance between classes for free prototype chaining
  • Call super() instead of Parent.call() for less confusion

However, it still operates on prototypes behind the scenes. So understanding prototypal behavior is important even when using modern syntax like classes.


Constructor Functions vs Classes: When to Use Each


With both constructor functions and ES6 classes available for generating objects, when should you use each approach? Here are some general guidelines:


Use constructor functions when:


  • You need to support older browsers lacking ES6 class compatibility
  • You need dynamic inheritance hierarchies at runtime
  • You want to create singletons by calling a constructor without new

Use ES6 classes when:


  • You want cleaner, more concise syntax
  • You know inheritance hierarchies ahead of time
  • Your codebase already uses classes so it’s consistent
  • You only care about supporting modern browsers

In reality, classes compile down to constructor functions and prototypes. So classes don’t introduce new capabilities, just friendlier syntax.


Constructors provide lower-level control while classes act more like syntactic sugar. Consider performance vs developer experience trade-offs when choosing between plain functions and ES6 classes for modeling JavaScript objects.


Encapsulation


Encapsulation in OOP refers to bundling data and methods into a single unit (a class), while preventing external access to certain properties and methods. Encapsulation provides two major advantages:


Hiding Complexity


It allows hiding the internal implementation details of class. Other code does not need to know unnecessary complexity - it only interacts through a simple public interface. Encapsulation enables abstraction through limited access.


For example:


class BankAccount {
    // Private property not accessible externally
    #pinCode; 
    constructor(pin) {
        this.#pinCode = pin;
    }
    // Public login method provides access check  
    login(pinAttempt) {
        return this.#pinCode === pinAttempt; 
    } 
}

const bankAccount = new BankAccount(1234);

// Pin code is not accessible directly  
console.log(bankAccount.#pinCode); // Syntax error

// Login method grants indirect access
bankAccount.login(1234); // true

This keeps the #pinCode encapsulated within BankAccount instead of being directly accessible like typical public properties.


Data Access Control


It allows controlling where and how properties are accessed or modified. By limiting visibility and access points, encapsulation makes it easier to enforce invariants.


For example, a Person class can check for valid ages being set rather than allowing direct modification of that property from anywhere:


class Person {
    constructor(name, age) {
        this.name = name; 
        this.setAge(age);
    }
    setAge(newAge) {
        if (newAge > 0 && newAge < 150) {
            this.age = newAge;
        } else {
            console.log("Not a valid age!");
        }
    }
}

const person = new Person("James", 33);
person.age = -10; // Not directly allowed!

person.setAge(-10); // Logs "Not a valid age!"

This restricts invalid age values from being set by providing a single controlled point of access.


Encapsulation through methods like this facilitates stricter data constraints.


Polymorphism


Polymorphism in OOP refers to a child class extending a parent class while also overriding or extending its behavior. With polymorphism, we can interact with related objects in the same manner regardless of their actual underlying type.


Let’s take our vehicle example from earlier:


class Vehicle {
    accelerate() {
        console.log("Accelerating");
    }

}

class Car extends Vehicle {
    accelerate() {
        super.accelerate();
        console.log("Rolling on four wheels");
    }
}

class Bike extends Vehicle {
    accelerate() {
        super.accelerate();
        console.log("Rolling on two wheels");
    }
}

// Polymorphic calls
const car = new Car();
const bike = new Bike();

car.accelerate(); 
bike.accelerate();

Even though Car and Bike have their own specific implementations, we can polymorphically call accelerate() on both in the same way through their shared parent class interface.


This allows interacting with related objects generically without needing to know concrete sub-types, as long as they support the base interface.


JavaScript enables ad-hoc polymorphism dynamically through duck typing. Classes/prototypes facilitate stricter polymorphism with type inheritance hierarchies and overriding.


This polymorphic flexibility helps reduce code duplication across types following the DRY principle. Strict typing is not required as in languages like Java or C#.


Composition vs Inheritance


So far we’ve explored modeling relationships between entities using inheritance hierarchies with parent and child classes. However, another approach for code reuse in OOP is composition.


With composition, objects can reference other objects or functions through their properties to delegate some of their behavior.


Let’s contrast inheritance and composition:


Inheritance


We use base classes to define common logic:


class Animal {
    makeNoise() {
        console.log("Some noise!");
    }
}

class Dog extends Animal {
    // Inherits makeNoise method
}

Pros:


  • Promotes hierarchy for conceptual relationships
  • Reuses logic that applies to specializations

Cons:


  • Brittle hierarchies with too many layers
  • Can inherit unnecessary features
  • Tight coupling

Composition


We define reusable components as stand-alone objects:


const noiseMaker = {
    makeNoise() {
       console.log("Noise!");  
    }
};

class Dog {
    constructor() {
        this.noiseMaker = noiseMaker; 
    }
}

Pros:


  • Flexible part-whole relationships
  • Loose coupling
  • Swappable dependencies

Cons:


  • No standard conceptual hierarchy
  • Dependencies must be passed around manually

In practice composition tends to provide more maintainable code than inheritance alone, serving as a great companion paradigm to OO programming. Libraries like React favor composition over classes for building reusable UI components.


Object Relationships and Data Structures


So far we’ve seen modeling inheritance relationships between objects with prototypal chains. We can also leverage OOP principles to implement common data structures to organize information.


Modeling Object Associations


Objects can be associated with other objects to compose larger structures:


Aggregation


This represents a has-a relationship where an object contains a reference to another object:


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

class Book {
    constructor(title, author) {
        this.title = title; 
        this.author = author;
    }
}

const author = new Author("John"); 
const book = new Book("Some Book", author);

book.author.name; // "John"

Here Book aggregates an Author object to model the real-world relationship between books and authors.


Composition


This is a stronger type of aggregation with tight ownership helping reuse. If a composite object gets destroyed, so do its belonging objects.


Implementing Data Structures


We can leverage OOP concepts like inheritance and aggregation to implement data structures like:


Linked Lists


A sequence of Node objects linked through references:


class Node {
    constructor(value, next) {
        this.value = value;
        this.next = next;
    }
}

let head = new Node(1, null); 
head.next = new Node(2, null); 

Trees


Node hierarchy where each parent node references child nodes as branches:



           A 
         / | \
        B  C  D
       / \
      E   F

We can build up these structures by composing objects together. OOP promotes thinking about relationships between components.


Modules and Namespaces


As JavaScript applications grow in scope, effectively organizing code becomes crucial to manage complexity. OOP principles provide logical segmentation of code into classes and objects. JavaScript also supports division of code at a file system level through modules and namespaces.


Motivation


Without modules or namespaces:


  • Many application scripts get bundled into a large codebase
  • Pollutes global namespace with many variables and functions
  • Hard to track dependencies and interactions between entities
  • Testing, maintaining, refactoring complex

Namespaces


Namespaces logically group related functionality under a single global variable to avoid collisions:


const MyNamespace = {
    Util: {
        calculateTotal(x) {
            //...
        } 
    },
    UI: { 
        // UI methods 
    },
    // Etc...
};

Namespaces use objects to provide separation without file system divisions.


Native ES Modules


JavaScript files can export reusable entities and import dependencies explicitly between files:


// Module 'Util.js'
export function calculateTotal(x) {
    // ...
}

// Module 'App.js'
import { calculateTotal } from './Util.js';

This enforces clean imports/exports instead of global namespaces.


Modules work nicely with OOP by exporting classes and namespaces themselves can align with high-level objects. Clean namespaces and modules result in loosely coupled code critical for scaling JS apps.


Error Handling and Debugging


Robust error handling and debugging strategies are important for maintaining complex OOP systems.


Error Handling


Several best practices help with errors in OOP code:


  • Exception handling with try/catch blocks catch execution errors gracefully
  • Well-defined error types that get thrown/caught help isolate issues
  • Using custom error objects provides context for exceptions
  • Handling errors centrally in parent classes and catch globally

For example:


class ValidationError extends Error {
    // Custom error type
}

class Person {
    constructor() {  
        try {
            // Logic 
        } catch (err) {
            throw new ValidationError(err); 
        }
    }
}

try {
    const person = new Person();
} catch (err) {
    if (err instanceof ValidationError) {
       // Handle validation specifically 
    } else {
       // Other exceptions
    }

} 

This separates error handling concerns clearly across hierarchies.


Debugging OOP Systems


Debugging techniques useful for OOP include:


  • Logging object method calls and property state
  • Tracing application flow across objects/scopes with debuggers
  • Checking prototypes and inheritance chains are properly configured
  • Testing objects in isolation with unit tests (see next section)

The interconnected nature of classes means debugging requires tracking relationships between objects. Following good error handling practices goes hand in hand with easier debugging.


Testing and Maintainability


Testing helps catch errors and reduces debugging time through test suites targeting components.


Unit Testing Individual Classes


Each class can have dedicated unit tests validating object behavior in isolation:


const Person = require("./Person");

describe("Person", () => {
    test("initialization", () => {
       const person = new Person("Sarah", 23);
       expect(person.name).toEqual("Sarah");
       expect(person.age).toEqual(23); 
    });
});

This ensures classes work independently before integrating them into an app.


Facilitating Loose Coupling


Loosely coupled OOP code is easier to reconfigure and maintain over time. Strategies like:


  • Using interface-based programming over concrete types
  • Developing classes with single responsibilities
  • Favoring small modules that do one thing well

These best practices align with principles like the Open/Closed Principle where classes are open for extension but closed for modification.


Well structured and tested OOP code goes hand in hand with more maintainable code.


Advanced OOP Concepts


We’ve covered core object-oriented principles in JavaScript. A few more advanced topics worth mentioning:


Abstract Classes & Interfaces


Abstract classes provide incomplete implementations leaving some methods unimplemented to be filled in by child classes:


abstract class Animal {
    constructor(name) {
        this.name = name; 
    }
    eat() {
        // Eat implementation
    }
    // Must be implemented by subclasses  
    abstract makeSound(); 
}

class Dog extends Animal {
    constructor(name) {
        super(name);
    }
    makeSound() {
        console.log("Bark!");
    }
}

Interfaces define structure but not implementation. Classes implement interfaces similar to contracts:


interface Walker {  
    walk();
}

class Person implements Walker {
    // Must define walk() method
}

Design Patterns


Design patterns provide proven OOP solutions to common problems like:

  • Singleton - Class only allowing a single object instance
  • Factory - Creating objects without specifying exact type
  • Observer - Event-based communication between objects

Patterns help build maintainable code by leveraging programming experience.


Functional Mixins


JavaScript uniquely allows extending OOP code through mixins:


const flyMixin = {
fly() {
console.log("Flying");
}
}

class Bird {
// ...
}

Object.assign(Bird.prototype, flyMixin);

This “mixes in” fly method from a plain object into Bird prototype. JS flexibility allows functional and OOP blend.


Conclusion


We’ve explored main object-oriented concepts like encapsulation, inheritance, and polymorphism in JavaScript. OOP provides principles for managing code complexity through logical, real-world modeling of application structure and behavior.


JavaScript enables an adaptable approach to OOP that aligns with its dynamic nature. Developers can choose the right level of strictness through prototypes, classes, functional mixins and more.


As applications grow, OOP skills prove vital for clean architecture, maintenance, and working in teams. Mastering core object-oriented ideas unlocks JavaScript’s capabilities for large-scale development.


The language continues to rapidly adopt new syntax like classes to ease the transition from classical OOP. Understanding the core prototypes and inheritance mechanisms, however, remains essential.


With this solid grasp of theoretical and practical OOP knowledge, JavaScript developers can build robust, scalable applications.