OOP in JavaScript

Payal RatheePayal Rathee
11 min read

In this article, we will look at some Object Oriented Programming concepts like classes, objects, etc. Javascript doesn’t inherently support classes rather it uses a concept known as prototype. Let’s understand the prototypal behaviour of JS in depth.

Prototypal Behavior

Prototype

Prototype in JS are regular objects which consists of some properties which can be inherited by other objects. So, prototype can be seen as a blueprint which an object inherits.

  • Every function has a property named “prototype” which defines a blueprint for objects created via it when used as a constructor. This is where all objects get their prototype from.

  • Every object has a property [[prototype]] (or__proto__) which contains the reference for the prototype it inherits. So, every object has __proto__ which points to prototype object, prototype being an object also has a __proto__ pointing to parent prototype and this continues till root prototype object which has __proto__ set to null. This is referred to as prototype chaining.

  • When object is created using a constructor function via new keyword, prototype of the function gets assigned to the __proto__ of newly created object.

Summarizing above points, each object has __proto__ property. Function objects, in addition also contains a prototype property, so function objects have both protoype and __proto__. Objects are created using these constructor functions and while doing so, prototype of function is assigned to proto of object. So, prototypes originally generate from functions which are then assigned to objects. Root of all prototypes is Object prototype.

Prototype Chaining

Each object has its own properties and a reference to its prototype(referenced by __proto__). Prototype in return being an object has its own __proto__ and this chain continues till the root object in which __proto__ is set to null. So, JS first looks a value in object’s own set of properties, if not found, it then searches prototype of the object, if it still fails, it then searches in the next prototype in the chain until it finds the value or reaches null.

Let’s look at an example to understand prototypal behavior clearly,

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

Person.prototype.greet = function() {
    console.log("Hello")
}

const obj = new Object();
const person = new Person("Demo");

// obj and person get their __proto__ from respective cnstructors
console.log(obj.__proto__ === Object.prototype) // true
console.log(person.__proto__ === Person.prototype) // true

// Each prototype object also has a __proto__ pointing to Object.prototype, unless modified
console.log(Object.prototype.__proto__) // null
console.log(Person.prototype.__proto__ === Object.prototype) // true

// As Object and Person are function objects (constructor), they get __proto__ from Function.prototype
console.log(Object.__proto__ === Function.prototype) // true
console.log(Person.__proto__ === Function.prototype) // true

// Prototype chaining
console.log(person.name) // own property
person.greet() // Available in person.__proto__ - Inherited from Person.prototype
console.log(person.toString()) // Available in person.__proto__.__proto__ - Inherited from Object.prototype

If you create a object literal without using any constructor function, it is same as creating a new Obejct() or Object.create(Object.prototype). Newly created object literal has Object.prototype as its __proto__

Now, let’s look how objects are created in prototypal aspect of Javascript:

  • Define a constructor function

  • Assign properties using this keyword

  • Add functionalities in its prototype

  • Create objects using the new keyword.

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

Person.prototype.displayName = function() {
    console.log(this.name);
}

Person.prototype.displayEmail = function() {
    console.log(this.email);
}

const p1 = new Person("p1", "p1@test.com");
const p2 = new Person("p2", "p2@test.com");

p1.displayName(); // p1
p1.displayEmail(); // p1@test.com

p2.displayName(); // p2
p2.displayEmail(); // p2@test.com

Class (ES6+)

Classes are syntactic sugar build over prototypes. It provides clean structure for defining classes and instantiating objects from them. Let’s look at the class structure of above example:

class Person {

    constructor(name, email) {
        this.name = name;
        this.email = email
    }

    displayName() {
        console.log(this.name);
    }

    displayEmail() {
        console.log(this.email);
    }
}

const p1 = new Person("p1", "p1@test.com");
const p2 = new Person("p2", "p2@test.com");

p1.displayName(); // p1
p1.displayEmail(); // p1@test.com

p2.displayName(); // p2
p2.displayEmail(); // p2@test.com

It internally creates a Person constructor function, adds defined methods in its prototype.

Object Oriented Programming Concepts

NEW Keyword

new keyword is used with constructor function to create a new object and bind it with the function call. It does the following:

  • Creates a new object.

  • Assigns __proto__ from constructors prototype.

  • Binds the object with function call using this.

  • Executes the constructor on newly created object and returns the object.

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

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

const p1 = new Person("p1");
p1.greet(); // Hello p1

Constructor

Constructor is a method used to initialize an object. It assigns all the initial properties to newly created object. Just like regular functions, it also has access to this keyword which holds reference to the current object. After initialization, it returns the newly created object.

// ------------------- PROTOTYPE Based
function Person(name) {
    this.name = name;
}
const p1 = new Person("p1");

// ------------------- CLASS Based
class Person {

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

}
const p1 = new Person("p1");

Getters and Setters

Getters and setters are methods used to get value of a property or set a value.

  • Each property has internal getters and setters whcih can be overriden using get or set keywords.

  • When a property is accessed using dot(.) operator, getter is called, while when a property is assigned a value using dot(.) operator, setter is called.

  • Getters can be used to return formatted values while setters can be used to store values after proper checks and sanitizations.

// ------------------- PROTOTYPE Based
function Person(name) {
    const parts = name.split(" ");
    this.firstName = parts[0];
    this.lastName = parts[1] || "";
}

Object.defineProperty(Person.prototype, "name", {
    get: function() {
        return this.firstName + " " + this.lastName;
    },
    set: function(name) {
        const parts = name.split(" ");
        this.firstName = parts[0];
        this.lastName = parts[1] || "";
    }
});

const p = new Person("Test User");
console.log(p.firstName) // Test
console.log(p.lastName) // User
console.log(p.name) // Test User

p.name = "Demo User";
console.log(p.name); // Demo User

// ------------------- CLASS Based
class Person {

    constructor(name) {
        const parts = name.split(" ");
        this.firstName = parts[0];
        this.lastName = parts[1] || "";
    }

    get name() {
        return this.firstName + " " + this.lastName;
    }

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

}

const p = new Person("Test User");
console.log(p.firstName) // Test
console.log(p.lastName) // User
console.log(p.name) // Test User

p.name = "Demo User";
console.log(p.name); // Demo User

Static Properties

Static properties are directly defined on constructor function and can be accessed on constructor or the class level. These properties doesn’t exist on the instance level.

// ------------------- PROTOTYPE Based
function Person(name) {
    this.name = name;
}

Person.prototype.a = 10; // can be accessed at instance level
Person.b = 20; // can't be accessed via instance

const p = new Person("Test");
console.log(p.a); // 10
console.log(p.b); // undefined
console.log(Person.b); // 20

// ------------------- CLASS Based
class Person {

    static a = 10;

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

    static greet() {
        console.log("Hello");
    }
}

const p = new Person("Test");
console.log(p.a); // undefined
console.log(Person.a); // 10
p.greet() // p.greet is not a function
Person.greet(); // Hello

Private Properties

Sometimes, properties of an instance needs to be hidden to keep internal implementations safe. These properties should not be accessible or modified outside the class. There are various ways adopted to make properties of an object private, let’s look at them one by one:

class Test {

    _secret; 

    constructor(name) {
        this._secret = name + "secret";
    }

    get name() {
        return this._secret.slice(0, this._secret.length-6);
    }

    set name(name) {
        this._secret = name + "secret";
    }

}

const test = new Test("Test");
test.name = "abc";  
console.log(test.name) // abc

console.log(test._secret) // abcsecret -- PROBLEM

In above example, though we haven’t exposed the internal property - _secret, it can still be accessed as it is an instance property, therefore it is not truly private.

class Test {

    constructor(name) {

        let _secret = name + "secret";

        this.getName = () => {
            return _secret.slice(0, _secret.length-6);
        }

        this.setName = (name) => {
            _secret = name + "secret";
        }
    }

}

const test = new Test("Test");
test.setName("abc");  
console.log(test.getName()) // abc

In above example, closure is used to define an internal property which can be accessed by getters and setters but not as an instance property, therefore it is truly private. But each instance will get it’s own copy of getters and setters, so it is not memory efficient.

Given below is the modern and clean way of defining private properties in a class -

class Test {

    #secret;

    constructor(name) {
        this.#secret = name + "secret";
    }

    get name() {
        return this.#secret.slice(0, this.#secret.length - 6);
    }

    set name(name) {
        this.#secret = name + "secret";
    }

}

const test = new Test("Test");
test.name = "abc"; 
console.log(test.name) // abc
console.log(test.#secret) // Private field '#secret' must be declared in an enclosing class

When # is used with a property it is reserved as private slot internally and can’t be accessed outside the class.

Similary, methods or static propeties can also be made private -

class Test {

    #secret;
    static #staticSecret = 'staticSecret';

    constructor(name) {
        this.#secret = name + "secret";
    }

    #extractName() {
        return this.#secret.slice(0, this.#secret.length - 6);
    }

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

    set name(name) {
        this.#secret = name + "secret";
    }

}

const test = new Test("Test");
test.name = "abc";
console.log(test.name);
console.log(test.#extractName()) // error
console.log(Test.#staticSecret) // error

Property Descriptors

Property decriptors are the objects used to configure instance properties. They can used to configure following values -

  • Value of the property

  • Read only or Writable

  • Enumerable (in loops)

  • Configurable (can be deleted or re-configured)

  • Getters and Setters

Every property has a property descriptor set for it. By default, following values are set internally -

  • writable: true

  • enumerable: true

  • configurable: true

const obj = {
    a: 10
};
Object.defineProperty(obj, "name", {
    value: "Test",
    writable: false,
    enumerable: false,
    configurable: false,
});

// NOT WRITABLE
obj.name = "Demo";
console.log(obj.name) // Test

// NOT ENUMERABLE
console.log(obj) //{a: 10, name: 'Test'}
for(let key in obj) {
    console.log(`${key} => ${obj[key]}`)
}
// a => 10

// NOT CONFIGURABLE 
Object.defineProperty(obj, "name", {
    value: "Test",
    writable: true,
    enumerable: false,
    configurable: false,
});
// Cannot redefine property: name

These properties can be used to define constants or stop iteration over a property. Similary, getters and setters can be defined -

const obj = {
    a: 10
};
Object.defineProperty(obj, "name", {
    value: "Test", // Invalid property descriptor. Cannot both specify accessors and a value or writable attribute
    writable: true, // Invalid property descriptor. Cannot both specify accessors and a value or writable attribute
    get: () => this.name + "abc",
    set: (name) => this.name = name.toUpperCase()
});

obj.name = "Test";
console.log(obj.name); // TESTabc

value and writable properties are not accepted if getters and setters are provided.

getOwnPropertyDescriptor() can be used to view property decriptor for an instance property.

You can define property descriptors in classes and constructor functions similarly as we did for object literals -

// ------------------- PROTOTYPE Based
function Person(name) {
  Object.defineProperty(this, "name", {
    value: name,
    writable: false,
    enumerable: true
  });
}

// ------------------- CLASS Based
class Person {
  constructor(name) {
    Object.defineProperty(this, "name", {
      value: name,
      writable: false,
      enumerable: true
    });
  }
}

Inheritance

Inheritance is an OOP concept in which child instance can inherit parent properties. In Javascript, it is implemented using prototype chaining. Let’s look at some examples to understand:

// ------------------- PROTOTYPE Based
function Animal(name) {
    this.name = name;
}
Animal.prototype.displayName = function() {
    console.log(this.name);
}

function Dog(name, breed) {
    Animal.call(this, name);
    this.breed = breed;
}
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
Dog.prototype.speak = function() {
    console.log(`${this.name} barks...`);
}

const dog = new Dog("Bruno", "Beagle");
dog.displayName(); // Bruno
dog.speak(); // Bruno barks...

// ------------------- CLASS Based
class Animal {
    constructor(name) {
        this.name = name;
    }
    displayName() {
        console.log(this.name);
    }
}

class Dog extends Animal {
    constructor(name, breed) {
        super(name);
        this.breed = breed;
    }
    speak() {
        console.log(`${this.name} barks...`);
    }
}

const dog = new Dog("Bruno", "Beagle");
dog.displayName(); // Bruno
dog.speak(); // Bruno barks...

Encapsulation

Encapsulation is an OOP concept in which data(properties) and methods(functions) are wrapped up as single unit with proper access control. It involves hiding internal properties and exposing only the required ones, reffered to as data hiding.

class User {
  #password; // private field

  constructor(username, password) {
    this.username = username;
    this.#password = password; 
  }

  getPassword() { 
    return "****"; // controlled access
  }

  setPassword(newPassword) {
    if (newPassword.length > 5) {
      this.#password = newPassword;
    } else {
      console.log("Password too short!");
    }
  }
}

const u = new User("Test", "secret123");
console.log(u.username); // Test
console.log(u.getPassword()); // ****
u.setPassword("abc"); // Password too short!

Polymorphism

Polymorphism is an OOP concept in which a single method can have different behaviors based on how it is called. Method overriding is used to implement this concept.

class Animal {
  speak() {
    console.log("Animal makes a sound");
  }
}

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

const a = new Animal();
const d = new Dog();
a.speak(); // Animal makes a sound
d.speak(); // Dog barks

Abstraction

Abstraction is an OOP concept in which internal implementation details are hidden exposing only the required functionalities. This way user can control state and behavior of an object by creating instances and executing methods on them without actually worrying about the internal details. In built functions like toString(), array methods - map, filter, etc. are good examples of abstraction.

0
Subscribe to my newsletter

Read articles from Payal Rathee directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Payal Rathee
Payal Rathee