Object Oriented Programming in JavaScript: In-Depth
Before diving into OOPs concepts, let's understand how an object looks in JavaScript.
const user1 = {
// Properties
name: 'Sam',
age: 22,
// Methods
work: function (company) {
console.log(`\({this.name} works in \){company}`);
},
// newer syntax for methods
play() {
console.log(`${this.name} is playing`);
}
}
console.log(user1.name);
user1.work('Noogle');
user1.salary = 100000;
console.log(user1.salary);
In JavaScript, objects have just a bunch of fields/variables with some data as the value or a function. The variables containing data are called properties and the ones having a function are called methods.
In the above example, the variables with their values are written directly during object creation which is called an object literal. The issue with object literals is that there's no structure, it doesn't follow any pattern, and the fields are just decided while object creation. Ex: user1 in our example might look like an user, but it doesn't represent any users, cause there can be an user2 with completely different fields.
So, to give objects a structure constructor functions were introduced.
Constructor Functions
Constructor functions are just normal functions written in a special way to provide a blueprint or structure to our object. Constructor function names are written in PascalCase to distinguish it from normal functions and they don't have a return type.
Inside constructor function we leverage the 'this' keyword to refer to our object and set the fields as per structure and the constructor function needs to be called in a special manner such that 'this' would refer to our object instead of the globalThis.
function User(name, age) {
this.name = name;
this.age = age;
}
// Correct ways to use the contructor function
let user1 = {};
User.call(user1, 'Bruce', 45);
let user2 = {};
User.apply(user2, ['Clark', 30]);
let user3 = {};
let User3Creator = User.bind(user3);
User3Creator('Diana', 28);
// In all the above example this inside the function would refer to the binded object instead of globalThis.
// Wrong way
User('Peter', 50) // it simply calls the function, with the 'this' refering to globalThis
Now, the objects created would have some structure as they follow the blueprint of the User, so we can identify them as a user.
NOTE: As we have a blueprint now, if there's some property we'd like think of from the perspective of the blueprint instead of indivial objects, we can store that in the blueprint itself i.e. in out case User (This works because in JavaScript, functions itself are objects). We can directy add more fields to the User and refer them using the dot notation. These fields are called static variables.
User.SPECIES = "homosapiens"; // static variable of User constructor function.
Methods and Prototype Linking
If we want to add methods for our objects, we might be tempted to write it inside the constructor function itself. Which will work, but there's a huge catch which makes it extremely inefficient.
// Inefficient way to add methods
function User(name, age) {
this.name = name;
this.age = age;
this.play = function () {
console.log(`${this.name} is playing`);
}
}
let user1 = {};
User.call(user1, 'Bruce', 45);
let user2 = {};
User.call(user2, 'Clark', 30);
user1.play();
user2.play();
In the above example, the play() method will work perfectly fine, but eventhough the method definition stays exactly the same, it'll take twice the memory. WHY? cause in JavaScript functions are treated as firstclass citizen, and thus the methods are individually created and take separate memory in each object.
So, to share memory in case the variable holds a function inside an object i.e. a method. We leverage prototype linking.
function User(name, age) {
this.name = name;
this.age = age;
}
// Memory efficient way to add methods
User.prototype.play = function () {
console.log(`${this.name} is playing`);
}
let user1 = Object.create(User.prototype);
User.call(user1, 'Bruce', 45);
let user2 = Object.create(User.prototype);
User.call(user2, 'Clark', 30);
user1.play();
user2.play();
Prototype and Prototype Linking
All functions have a property called prototype in which we actually set the methods. For this prototype to reflect in our objects, Javascript uses mechanisms to add secret __proto__ in our object and links it to the prototype of the constructor function. Let's understand it with multiple examples
function User(name, age) {
this.name = name;
this.age = age;
}
User.prototype.play = function () {
console.log(`${this.name} is playing`);
}
// Purely manual way
let user1 = { userPrototype: User.prototype }
User.call(user1, 'Bruce', 45);
user1.userPrototype.play(); // correct method is referenced but will print "undefined is playing" cause the caller is User instead of user1.
// Semi-manual way, adhering to JavaScript guidelines
let user2 = { __proto__: User.prototype }
User.call(user2, 'Clark', 30);
user2.__proto__.play(); // "undefined is playing"
user2.play(); // "Clark is playing"
// The above example works cause JavaScript understands and looks for the __proto__ field, and whose prototype field is attached here, BUT while calling the methods in prototype, it calls from the context of user1 instead of User.
// Automatic ways to achieve it -- Let JavaScript bind the prototype
let user3 = Object.create(User.prototype);
let user4 = {};
Object.setPrototypeOf(user4, User.prototype);
console.log(user3.__proto__ === User.prototype);
console.log(user4.__proto__ === User.prototype);
// now user3 and user4 will have a hidden __proto__ field binded to User.prototype and calling the play method directory from the user3, user4 objects will work
User.call(user3, 'Diana', 1000);
user3.play();
User.call(user4, 'Barry', 23);
user4.play();
NEW keyword
From the above learning, if I wanted to create a helper function to automate the creation of objects from a bluprint. I'd need to follow 4 steps inside the helper function
Create an object.
Link the __proto__ field of the object to the prototype field of the constuctor function.
Bind 'this' of the object to the 'this' used in constuctor function
Return the object
All of these 4 steps can be handled by using a single keyword called new
// Constructor Function i.e Blueprint
function User(name, age) {
this.name = name;
this.age = age;
}
User.SPECIES = "homosapiens";
User.prototype.play = function () {
console.log(`${this.name} is playing`);
}
// helper function
function createObject(Constructor, ...args) {
// 1. create an object
const obj = {};
// 2. link the prototype
Object.setPrototypeOf(obj, Constructor.prototype);
// 3. bind this of the object
Constructor.apply(obj, args);
// 4. return the object
return obj;
}
// creating object using helper function
const user1 = createObject(User, 'Bruce', 45);
// creating object using new keyword
const user2 = new User('Clark', 30);
// PS: wrong way
const user3 = User('Diana', 1000); // this is just calling the function and getting undefined cause there's no return type
new is extremely helpful as it completely automates our object creation process taking care of everything. But that's not all new also add more helpful features which we miss out when creating objects manually.
More advantages of new
instanceof keyword: objects created with new can be used with instanceof to check the type (Ex: console.log(user1 instanceof User); )
constructor property: objects created witn new add another property called constructor to refer to the constructor function from within. (Ex. console.log(user1.constructor.name))
CLASS syntax for writing blueprint
Currently there's a good logical grouping of the object creation process using the new keyword, but there's no proper grouping of the blueprint. This solved by the new class keyword
// Old Style
function User(name, age) {
this.name = name;
this.age = age;
this.friends = [];
}
User.SPECIES = "homosapiens";
User.prototype.play = function () {
console.log(`${this.name} is playing`);
};
User.prototype.addFriend = function (name) {
this.friends.push(name);
};
// New Style
class User {
static SPECIES = "homosapiens";
friends = [];
constructor(name, age) {
this.name = name;
this.age = age;
}
play() {
console.log(`${this.name} is playing`);
}
addFriend(name) {
this.friends.push(name);
};
}
