Skip to the content.

JavaScript Advanced

Mutability vs Immutability

Mutability : A mutable value is one that can be changed without creating an entirely new value. Immutability : An immutable value is one whose content cannot be changed without creating an entirely new value. All primitive types in JavaScript are immutable.

Immutable types

  1. String:
  2. Number:
  3. Boolean:
  4. Undefined:
  5. Null:
  6. Symbol:

Once a primitive value is created, it cannot be changed, although the variable that holds it may be reassigned another value.

Immutable types are pass by value meaning when you copy the value of a primitive data, it’s value is stored in a new memory location.

let.x = 10;
let y = x;

In the above case, both x and y refer to a differnet memory location with value 10 in it.

Mutable types

  1. Object
  2. Array

In mutable types, updating the variable acutlly updates the value.

let arr = [1, 2, 3];
arr.push(4); //This operation actually updates the value stored in the variable.

Mutable types are pass by reference meaning when you copy the value of a non primitive data, the reference(memory address) of the first variable is copied.

let arr = [1, 2, 3];
let arr1 = arr;

In the above case, both arr and arr1 refer to the same memory location where [1,2,3] is stored. If we update the value in any one of the variable, the other get’s the updated value.

let arr = [1, 2, 3];
let arr1 = arr;
arr1.push(4);
console.log(arr1);
console.log(arr);

In the above case, both will print [1,2,3,4]

Callback and Callback Hell

Here’s a simple example to illustrate the concept:

function fetchData(callback) {
  setTimeout(function () {
    const data = { name: "John", age: 30 };
    callback(data);
  }, 1000); // Simulating an asynchronous operation
}

function processData(data) {
  console.log("Processing data:", data.name);
}

fetchData(processData);

Callback hell

Example:

asyncFunction1(function (result1) {
  asyncFunction2(result1, function (result2) {
    asyncFunction3(result2, function (result3) {
      // ... and so on
    });
  });
});

This causes multiple issues in terms of readability, error handnling and scalability, hence we make use of another concept called promise to handle such situation.

Promise

Creating a Promise

A sample example:

function executorFunction(resolve, reject) {
  try {
    //Do some async work (or sync)
    setTimeout(() => {
      const data = "Apple";
      resolve(data);
    }, 2000);
  } catch (e) {
    reject(e);
  }
}

const myPromise = new Promise(executorFunction);

Usage:

myPromise()
  .then((data) => {
    console.log("Data:", data);
  })
  .catch((error) => {
    console.error("Error:", error.message);
  });

Three phases:

Three methods

NOTE: Each .then() returns a newly generated promise object, which can optionally be used for chaining; for example:

Chained Promise

myPromise
  .then(handleFulfilledA, handleRejectedA)
  .then(handleFulfilledB, handleRejectedB)
  .then(handleFulfilledC, handleRejectedC);

Important Note

Other Major Promise methods:

  1. Promise.all: Waits for all promises to resolve or any to reject, returning an array of results.
  2. Promise.race: Waits for the first promise to resolve, ignoring rejections until all fail.
  3. Promise.any: Resolves or rejects with the value of the first promise to settle (either resolve or reject).

Even though promise is a better alternative to callbacks, in ES6, a new concept called Async Await was introduced.

Async Await

  1. Synchronous Look: Provides a synchronous-like syntax for handling asynchronous operations.
  2. Awaiting Promise: Pauses execution until a promise is resolved, then returns its value.
  3. Error Handling: Simplifies error handling with try/catch blocks around asynchronous code.
  4. Sequential Logic: Easily chain and await multiple async calls for sequential logic.
  5. Modern Alternative: Offers cleaner code compared to nested callbacks or chaining .then().

Example:

async function fetchData() {
  try {
    const data = await fetchAPI();
    const processedData = await process(data);
    doSomething(data);
  } catch (error) {
    handleError(error);
  }
}

Difference between async await and promise

There is a very subtle difference when it comes to the execution of these two concepts.

With async wait, the execution of a function stops when it witnesses the await statement and moves out of the function until the async process is completed. Whereas with promise, the execution continues in the same function, it just skips the promise statement until the async process is completed.

Example:

//With Promise
function doSomething() {
  console.log(1);
  somePromise.then((x) => console.log(2)).catch(); //Some promise that resolves with 2
  console.log(3);
}
doSomething();
console.log(4);

//Output
1, 3, 4, 2;
//With Async Await
async function doSomething() {
  console.log(1);
  const resp = await somePromise(); //Promise resolves with
  console.log(resp);
  console.log(3);
}
doSomething();
console.log(4);
//Output
1, 4, 2, 3;

Prototype

const person = {
	name: "Ish",
	getName(){
		console.log(`My name is ${this.name}`)
	}
}
console.log(person.getName())
//Output
My name is Ish

As we can see, getName is a method inside person object, so person.getName() gives me the desired output. But what if I do something like

person.toString();
//Output
("[object Object]");

As you can see, toString() on the object person is not throwing error, rather it’s giving me a unwanted value. This is possible because the method toString()belongs to the prototype of the object person.

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.

If I try to access the prototype of the object person, I get following results:

1.  {constructor: ƒ, __defineGetter__: ƒ, __defineSetter__: ƒ, hasOwnProperty: ƒ, __lookupGetter__: ƒ, }
1.  constructor: ƒ Object()
2.  hasOwnProperty: ƒ hasOwnProperty()
3.  isPrototypeOf: ƒ isPrototypeOf()
4.  propertyIsEnumerable: ƒ propertyIsEnumerable()
5.  toLocaleString: ƒ toLocaleString()
6.  toString: ƒ toString()
7.  valueOf: ƒ valueOf()
8.  __defineGetter__: ƒ __defineGetter__()
9.  __defineSetter__: ƒ __defineSetter__()
10.  __lookupGetter__: ƒ __lookupGetter__()
11.  __lookupSetter__: ƒ __lookupSetter__()
12.  __proto__: (...)
13.  get __proto__: ƒ __proto__()
14.  set __proto__: ƒ __proto__()

You can view the prototype of any object using person.__proto__ (which is depricated, we can instead make use of Object.getPrototypeOf(person))

Prototype of a function

function fruit() {}
console.log(fruit.prorotype);
//Output
{
  constructor: ƒ;
}

As we can see above, the prototype of a function is an object which has a key called constructor

console.log(fruit.prototype.constructor);
//Output
ƒ fruit(){}

As we can see above, the constructor references to the same function called fruit (the original function)

The following holds true,

function Person() {}
console.log(Person.prototype.constructor === Person); // Outputs: true
const person = new Person();
console.log(person.constructor === Person); // Outputs: true

Hence the object created using constructor function has information about it’s constructor. Note: We will discuss more about this while discussing constructor funciton

Shadowing properties: Only check the prototype if the property doesn’t exist in the current object.

Setting a prototype

  1. Object.create
const person = {
  greet() {
    console.log("Hello");
  },
};
const me = Object.create(person);
me.greet(); //Hello
  1. Constructor (Will discuss more in detail later)

Inheritance

Constuctor Function

Constructor Functions can be used to create objects and achieve inheritance (prototypical inheritance) as discussed above.

function Person(name) {
  this.name = name;
}
const Ish = new Person("Ish");
console.log(Object.getPrototypeOf(Ish))
//Output ->
{constructor: ƒ}
//which epands to
1.  constructor: ƒ Person(name)
2.  [[Prototype]]: Object

We can check the constructor of the Ish using Ish.constructor which returns me Person cosntructor.

console.log(Ish.constructor)
//Output ->
ƒ Person(name){
    this.name = name
}

Achieving inheritance using constructor funciton

Let’s write a code to achieve this:

// A base Constructor
function Animal(name) {
  this.name = name;
}
//Creating a function on the prototype of the constructor
Animal.prototype.getName = function () {
  console.log(this.name);
};

//A new constructor which inherits Animal base constructor (cat is animal...right?)
function Cat(type) {
  this.type = type;
  Animal.call(this, "Catty");
}

We did the basic stuff required to achieve inheritance (or did we?) let’s check few things below

const myCat = new Cat("Fluffy");
console.log(myCat.type); // Flyffy
console.log(myCat.name); // Catty
//Yaeeey

console.log(myCat.getName()); //Uncaught TypeError: myCat.getName is not a function 🤷🏼‍♂️

As we can see above, myCat.type is giving me correct type, and even myCat.name is giving me corrent name, which it is getting from it’s prototype. But why is getName not a function?

Because even though we did Animal.call() inside Cat, it is still not inheriting the prototype of the Animal constructor. To do that, we need to inherit even the prototype of the Animal.

Cat.prototype = Object.create(Animal.prototype);

Using the above code, we are assigning the prototype of Animal constructor to the prototype of Cat constructor.

But why did we even create the function getName in the prototype and not directly inside the Construcor?

Every object created using the Construcor Function gets a copy of all the properties persent in the constructor. This may not be a good idea in some cases. Like it makes sense that every object get’s a differnet name, but why a different getName function? We can just make use of the same function reference in each and every object isn’t it? Yes, we’re doing the exact same thing.

How does a construcor function work internally?

When you call a constructor function in JavaScript using the new keyword, following steps are taken internally to create a new object instance and initialize its properties:

  1. A new empty object is created. This new object will become the instance of the constructor.
  2. The constructor function is called with this set to the newly created object. This allows the constructor to initialize the object’s properties and set up its initial state.
  3. The newly created object’s internal [[Prototype]] (or .__proto__) property is set to the prototype property of the constructor function. This establishes the prototype chain for inheritance.
function MyNew(constructorFn) {
  const obj = {}; //Step 1
  constructorFn.call(obj); //Step 2
  obj.__proto__ = constructorFn.prototype; //Step 3
  return obj;
}

NOTE If the constructor does not explicitly return an object, the newly created object is returned. If the constructor returns an object, that object takes precedence over the default object created in step 1.

(Overwhelmed? I can’t simplify this more in text, maybe my youtube video on the same will be helpful)

Class

Fratures

  1. Syntactic Sugar: Classes are syntactic sugar over constructor functions and prototypes.
  2. Constructor Method: The constructor method initializes object properties when a new instance is created.
  3. Instance Creation: Objects are created using the new keyword followed by the class name.
  4. this Context: Inside class methods, this refers to the instance the method is called on.
  5. Inheritance: Classes can extend other classes using the extends keyword for inheritance.
  6. super Keyword: The super keyword is used to call methods from the parent class.
  7. Constructor Property: Instances have a constructor property pointing back to the class.

Same example as above with classes:

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

class Cat extends Animal {
  constructor(type) {
    super("Catty");
    this.type = type;
  }
  getType() {
    console.log(this.type);
  }
}

const myCat = new Cat("Fluffy");
console.log(myCat.type); // Flyffy
console.log(myCat.name); // Catty
console.log(myCat.getName()); //Catty

Constructor Method Properties declared inside the constructor method will be copied to every instance(object) that’s created using the class.

  1. Properties defined inside the constructor method using this.propertyName are unique to each instance created from the class.
  2. When you create a new instance using the new keyword, the constructor is executed, and properties defined within the constructor are initialized for that specific instance.
  3. Each instance has its own set of properties, and changes made to these properties on one instance do not affect other instances.

Non constructor methods

  1. When you create a class using the class syntax, the methods defined inside the class are added to the class’s prototype.
  2. When you create an instance of the class using the new keyword, the instance has an internal reference ([[Prototype]] or .__proto__ depending on how you access it) to the class’s prototype.
  3. When you call a method on an instance, JavaScript looks up the prototype chain to find the method in the prototype. It doesn’t copy the method to the instance itself.
  4. This behavior is what allows for memory-efficient sharing of methods among instances.

NOTE We achieved the same in Constructor Function by defining the methods in the prorotype of the construcor rather than directly defining inside it.

this

Here are a few key points to understand about this in JavaScript:

  1. Global Context: In the global context (outside of any function), “this” refers to the global object, which is often the “window” object in a browser environment or the “global” object in Node.js.
console.log(this === window); // In a browser, true
  1. Function Context: In a regular function (not an arrow function), “this” is determined by how the function is called. It can be influenced by the object the function is a property of (the calling object) or by how the function is invoked using methods like “call”, “apply”, or “bind”. By default this refers to the global window object in a regular funciton.
let name = "Global Ish";
function myName() {
  let name = "Ish";
  console.log(this.name);
}
myName(); //This will refer to the global name Global Ish.
  1. Method Invocation: When a function is called as a method of an object, “this” refers to the object that the method belongs to. In this case, “this” points to the object to the left of the dot when calling the method.
let name = "Global Ish";
const person = {
  name: "Ish",
  greet: function () {
    console.log(`Hello, my name is ${this.name}`);
  },
};
person.greet(); // Hello, my name is Ish
let x = person.greet;
x(); // Hello, my name is Global Ish
  1. Constructor Functions: When a function is used as a constructor using the “new” keyword, “this” refers to the newly created instance of the object.
function Person(name) {
  this.name = name;
  this.greet = function () {
    console.log(`Hello, my name is ${this.name}`);
  };
}
const person1 = new Person("Bob");
person1.greet(); // "this" refers to the instance "person1"
  1. Arrow Functions: Arrow functions do not have their own “this” context; they inherit the “this” value from the surrounding code.
let name = "Global Ish";
const person = {
  name: "Ish",
  getName: () => {
    console.log(`Hello, my name is ${this.name}`);
  },
};
person.getName(); // Hello, my name is Global Ish

This might be a bit tricky to comprehend. It was very confusing for me.

As stated above, arrow function inherit the “this” value from the surrounding code. In this case, the sorrounding code is person object, which in turn is in the global scope. Remember from the main definition: this in JavaScript refers to the context within which a function(not object) is executed

  1. Explicit Binding: Functions like “call”, “apply”, and “bind” can be used to explicitly set the value of “this” for a function.
function sayHi() {
  console.log(`Hi, ${this.name}`);
}
const user = { name: "Ish" };
sayHi.call(user); // "this" refers to the "user" object

Pure functions

  1. Output Deterministic: Pure functions always produce the same output for the same input, making them predictable.
  2. No Side Effects: Pure functions do not modify external state or have observable side effects.
  3. Caching Benefits: Pure functions can be cached or memoized for performance optimization.
  4. Maintainable Code: Using pure functions promotes cleaner, more maintainable code.
// Pure function: Adds two numbers without side effects
const add = (a, b) => a + b;

Below is an example of a function that’s not pure

let total = 0;
function addToTotal(value) {
  total += value; // Modifies external state (side effect)
}
addToTotal(10); // Side effect: Modifies "total" variable
console.log(total); // Outputs: 10

As you can see above, every time you call the funciton addToTotal , it updates the external value, hence causing side effect.

Proxy

A promise is a proxy of a value that is not yet recieved. Proxy is a built-in object that allows you to intercept and customize the fundamental operations of another object, known as the target object.

//A basic Proxy with no handler object
const target = {
  name: "Ish",
  age: 30,
};
const person = new Proxy(target, {});
console.log(person.name); //Ish
const  target  = {
	name:  "Ish",
	age:  30,
};
const  person  =  new  Proxy(target, {
	//overwrite the get method of an object
	get(target, property) {
		return  `I am ${target[property]}`;
	}
)
person.name = "Ish"
console.log(person.name); // I am Ish

Symbol

In JavaScript, a Symbol is a primitive data type introduced in ECMAScript 6 (ES6) that represents a unique and immutable value. Symbols are often used as keys for object properties to avoid naming conflicts and ensure that property names won’t accidentally collide.

  1. Uniqueness: Each Symbol value is unique, even if the symbols have the same description. This uniqueness makes them useful for creating private or hidden object properties.
  2. Hidden Properties: Symbols can be used to define properties that are not easily accessible using iteration or reflection mechanisms like for...in loops or Object.keys().

Symbol.iterator

The Symbol.iterator is a well-known symbol in JavaScript that allows you to define custom iteration behavior for objects. This symbol is used to implement the iterable protocol, which enables objects to be used with the for...of loop and other built-in iteration methods.

const range = {
  start: 1,
  end: 5,
  [Symbol.iterator]() {
    let current = this.start;
    return {
      next: () => {
        if (current <= this.end) {
          return {
            value: current++,
            done: false,
          };
        } else {
          return {
            done: true,
          };
        }
      },
    };
  },
};

for (const num of range) {
  console.log(num);
}
//Output
1;
2;
3;
4;
5;

Memory Leaks

Memory leaks in JavaScript can occur when references to objects are retained, preventing the JavaScript garbage collector from properly reclaiming memory that is no longer needed.

  1. Unintentional Global Variables: Variables declared without the var, let, or const keywords (implicitly global variables) can cause memory leaks if they hold references to objects that should be garbage collected.
  2. Closures: Closures that capture references to variables outside their scope can lead to memory leaks if the closures are not properly managed.
  3. Event Listeners: Forgetting to remove event listeners when they are no longer needed can result in objects (such as DOM elements) being held in memory even after they are no longer reachable.
  4. Circular References: Objects that reference each other in a circular manner can prevent the garbage collector from reclaiming memory.
  5. Timers and Intervals: Not clearing or disposing of timers or intervals can cause objects associated with them to remain in memory.
  6. DOM References: Keeping references to DOM elements that are no longer needed can lead to memory leaks, especially in single-page applications.

Garbage collection

Mark and Sweep Algorithm: The most common garbage collection algorithm used is the “mark and sweep” algorithm. It involves two main phases: marking and sweeping.

Triggers

  1. Memory Pressure : When available memory is low.
  2. Allocation threshold: When the no of allocated objects exceed the set threshold.
  3. Time based: Based on some fixed time.

Currying

//Without currying
function add(x, y, z) {
  return x + y + z;
}
add(1, 2, 3); // 6

//With currying
function add(x) {
  return function (y) {
    return function (z) {
      return x + y + z;
    };
  };
}
add(1)(2)(3);

Cascading

fetchData("api.example.com").then().then().catch().finally();

Object freeze vs seal

Object.freeze()

const person = { name: "Ish" };
Object.freeze(person);

person.age = 30; // Won't add the 'age' property
person.name = "Rimal"; // Won't modify the 'name' property
delete person.name; // Won't delete the 'name' property

Object.seal()

const person = { name: "Ish" };
Object.seal(person);

person.age = 30; // Won't add the 'age' property
person.name = "Rimal"; // Can modify the 'name' property
delete person.name; // Won't delete the 'name' property
Operation Freeze Seal
Create Not Allowed Allowed
Read Allowed Allowed
Update Not Allowed Not Allowed
Delete Not Allowed Not Allowed

Object.defineProperty

Define or modify properties of an object with more control. Every property of an object can have 3 peroperty attributes, which can be used to provide some property.

  1. writable:
const obj = {};
Object.defineProperty(obj, "readOnlyProp", {
  value: 42,
  writable: false, // Cannot be changed after initialization
});

obj.readOnlyProp = 100; // No effect due to writable: false
  1. enumerable:
const obj = { a: 1, b: 2 };
Object.defineProperty(obj, "hiddenProp", {
  value: 42,
  enumerable: false, // Won't show up in iterations
});
for (const prop in obj) {
  console.log(prop); // Outputs: "a" and "b", not "hiddenProp"
}
  1. configurable:
const obj = {};
Object.defineProperty(obj, "configurableProp", {
  value: 42,
  configurable: false, // Attributes cannot be modified, property cannot be deleted
});

delete obj.configurableProp; // No effect due to configurable: false

Object.defineProperty(obj, "configurableProp", {
  enumerable: true,
}); // Error due to non-configurable property

Generator Functions

Example-

function* numberGenerator() {
  yield 1;
  yield 2;
  yield 3;
}

const generator = numberGenerator();

console.log(generator.next().value); // Outputs: 1
console.log(generator.next().value); // Outputs: 2
console.log(generator.next().value); // Outputs: 3
console.log(generator.next().value); // Outputs: undefined