Dec 01, 2022Last modified July 8, 2025

Javascript Deep Dive

Even after working on javascript for a few years, I still feel like I am learning new things about JS. I have been reading the YDKJS book series and there are some really good insights (learnings) in the book which I am going to share in this article.

I did refer other internet resources to understand some of the concepts. So there are few pointers which didnt come from the book. Additionally, as I started deep diving on different concepts, I found few more concepts which I didnt understand fully. So I have added some notes from that exploration as well.

Javascript Engine and Runtime

Credit for pointers

  • Javascript engine runs as a part of javascript runtime, which is generally a browser or a node js server.
  • There are several JS engines available, the most famous one is the v8 engine, implemented inside Chrome browser and Node.js, but there is also Spidermonkey (used by Firefox) or Chakra (used by IE and Edge).
  • All JS engines are implemented to follow the ECMAScript standard. Thats why they all behave in same way.
  • JS engine is responsible for reading the JS code, tokenizing & parsing it, then ccompiling it into bytecode and finally executing it.
  • It is single-threaded, meaning that it executes code sequentially, one statement at a time, within a single thread of execution (in the browser it could be a tab for example, so each tab has an instance of the JavaScript engine it implements).
  • It includes two main components, the call stack and the heap (see image below). The call stack is a data structure that keeps track of the function that is being executed and of the subsequent ones.
  • The heap contains information such as the function’s arguments, local variables and the location in the code where the function was called. The heap instead is a dynamic allocated region of memory that stores the address (not the value!) of objects, arrays and other data structures created during a function execution.
jsruntime
Javascript Runtime. Image credit

Scopes

  • Scopres are essentially visibility rules which dictate what part of your program can acccess which variables.
  • Whenever you declare a variable in JS and initialize it, the compiler first creates the variable in memory and assigns a scope to it. During execution, the JS engine will look up the variable in the scope chain and then assigns the value.
  • There are two types of lookups which generally happen in JS during execution - LHS lookup and RHS lookup.
  • LHS lookup happens for the variable who we are assigning the value to. RHS lookup happens for the variable which we are trying to access.
var name = "Global John";

function greet() {
var name = "Local Jane";
console.log("Hello, " + name); // Which 'name' gets used?
}

greet(); // "Hello, Local Jane"
console.log(name); // "Global John"
var name = "Global John";

function greet() {
var name = "Local Jane";
console.log("Hello, " + name); // Which 'name' gets used?
}

greet(); // "Hello, Local Jane"
console.log(name); // "Global John"
  • JavaScript needs rules to decide which name variable to use when there are multiple ones! These specific rules are called scope.
  • Scopes can be nested. Whenver a particular variable is not found in the current scope, the engine looks up the scope chain until it finds the variable or reaches the global scope.
  • Lexical scope - Javascript scopes are lexical scopes. Meaning during parsing/compilation phase, the compiler will look at the location where the variables and functions are declared and then create scope rules for them accordingly. Lexical scope = static scope.
  • You can modify scopes using the eval function and with keyword.
  • eval() function takes a string as an argument and executes it as if it were a program. Evals are discouraged due to security concerns related to code injection attacks. Many sites use CSPs (Content Security Policies) to prevent eval() from being used.
// Simple evaluation
eval("console.log('Hello from eval!')"); // "Hello from eval!"

// Mathematical expressions
var result = eval("2 + 3 * 4"); // 14
console.log(result);

// Variable creation
eval("var dynamicVar = 'I was created by eval!'");
console.log(dynamicVar); // "I was created by eval!"

// Function creation
eval("function dynamicFunction() { return 'Dynamic!'; }");
console.log(dynamicFunction()); // "Dynamic!"
// Simple evaluation
eval("console.log('Hello from eval!')"); // "Hello from eval!"

// Mathematical expressions
var result = eval("2 + 3 * 4"); // 14
console.log(result);

// Variable creation
eval("var dynamicVar = 'I was created by eval!'");
console.log(dynamicVar); // "I was created by eval!"

// Function creation
eval("function dynamicFunction() { return 'Dynamic!'; }");
console.log(dynamicFunction()); // "Dynamic!"
  • The with keyword is used to create a scope. It takes an object as an argument and then all the properties of the object are added to the scope.
var person = {
name: "John",
age: 30,
city: "NYC"
};

// Without 'with' - repetitive
console.log(person.name);
console.log(person.age);
console.log(person.city);

// With 'with' - shorter but problematic
with (person) {
console.log(name); // "John"
console.log(age); // 30
console.log(city); // "NYC"
}
var person = {
name: "John",
age: 30,
city: "NYC"
};

// Without 'with' - repetitive
console.log(person.name);
console.log(person.age);
console.log(person.city);

// With 'with' - shorter but problematic
with (person) {
console.log(name); // "John"
console.log(age); // 30
console.log(city); // "NYC"
}
  • Originally there were two main types of scopes - global scope and function scope.global and function. Global scope is the outermost scope and function scope is the scope created by a function. ES6 introduced block scope with the let and const keywords.
  • Using scopes allows to facilitate collision avoidance as well. This happens when two or more identifiers with the same name but different intended usages are declared.
  • Hoisting - Hoisting is a mechanism in JavaScript where variable and function declarations are moved to the top of their containing scope during the compilation phase, before the code is executed. This is what allows you to use variables and functions before they are declared in your code.
  • Function declarations are hoisted first and then variables.

Closures

  • Closure is when a function is able to remember and access its lexical scope even when that function is executing outside its lexical scope.

this

  • In javascript, the value of this is determined by the call site. Call site simply means the location in the code where a function is called.
  • Four rules for determining the value of this :
    • Default binding - by default this is set to the global (in node js) / window (in browser) object
    • New binding - when a function is called with the new keyword, the value of this is set to the newly created object.
    • Implicit binding - If a function is called on an objet, obj.foo(), this will be the object it was called on.
    • Explicit binding - When a function is called with the call or apply method, this is explicitly set to the first argument passed to call or apply.
  • Precedence of binding rules - new binding > explicit binding > implicit binding > default binding.
// ============================================
// RULE 1: DEFAULT BINDING
// By default 'this' is set to the global object (window in browser, global in Node.js)
// ============================================

function defaultBinding() {
console.log("Default Binding - this:", this);
console.log("Is this === window?", this === window); // true in browser
}

defaultBinding(); // 'this' points to global/window object

// In strict mode, default binding results in undefined
function strictDefaultBinding() {
"use strict";
console.log("Strict mode - this:", this); // undefined
}

strictDefaultBinding();

// ============================================
// RULE 2: NEW BINDING
// When function is called with 'new', 'this' is set to the newly created object
// ============================================

function Person(name, age) {
this.name = name;
this.age = age;
this.greet = function() {
console.log(`New Binding - Hello, I'm ${this.name}, age ${this.age}`);
console.log("'this' refers to:", this);
};
}

const person1 = new Person("John", 30);
const person2 = new Person("Jane", 25);

person1.greet(); // 'this' refers to person1 object
person2.greet(); // 'this' refers to person2 object

console.log("person1:", person1);
console.log("person2:", person2);

// ============================================
// RULE 3: IMPLICIT BINDING
// If function is called on an object (obj.method()), 'this' is the object
// ============================================

const user = {
name: "Alice",
age: 28,

showInfo: function() {
console.log("Implicit Binding - Name:", this.name);
console.log("Implicit Binding - Age:", this.age);
console.log("'this' refers to:", this);
},

// Nested object example
address: {
city: "New York",
showCity: function() {
console.log("Implicit Binding (nested) - City:", this.city);
console.log("'this' refers to address object:", this);
}
}
};

user.showInfo(); // 'this' refers to 'user' object
user.address.showCity(); // 'this' refers to 'address' object (immediate parent)

// Lost implicit binding example
const lostBinding = user.showInfo;
lostBinding(); // 'this' falls back to default binding (global/window)

// ============================================
// RULE 4: EXPLICIT BINDING
// Using call(), apply(), or bind() to explicitly set 'this'
// ============================================

const animal = {
name: "Dog",
sound: "Woof"
};

const cat = {
name: "Cat",
sound: "Meow"
};

function makeSound(volume, emotion) {
console.log(`Explicit Binding - ${this.name} says ${this.sound}!`);
console.log(`Volume: ${volume}, Emotion: ${emotion}`);
console.log("'this' explicitly set to:", this);
}

// Using call() - arguments passed individually
makeSound.call(animal, "loud", "excited");
makeSound.call(cat, "soft", "sleepy");

// Using apply() - arguments passed as array
makeSound.apply(animal, ["medium", "happy"]);
makeSound.apply(cat, ["quiet", "content"]);

// Using bind() - creates new function with bound 'this'
const dogSound = makeSound.bind(animal);
const catSound = makeSound.bind(cat);

dogSound("very loud", "playful");
catSound("whisper", "sneaky");

// ============================================
// BINDING PRECEDENCE ORDER (highest to lowest):
// 1. Explicit binding (call, apply, bind)
// 2. New binding (new keyword)
// 3. Implicit binding (object method call)
// 4. Default binding (global object or undefined in strict mode)
// ============================================

// Precedence example: Explicit vs Implicit
const obj = {
name: "Object",
test: function() {
console.log("Precedence test - this.name:", this.name);
}
};

const anotherObj = { name: "Another Object" };

obj.test(); // Implicit binding - "Object"
obj.test.call(anotherObj); // Explicit binding wins - "Another Object"

// Precedence example: New vs Implicit
function Constructor(name) {
this.name = name;
}

const obj2 = {
createInstance: Constructor
};

const instance1 = obj2.createInstance("Implicit"); // Lost binding, 'this' = global
const instance2 = new obj2.createInstance("New Binding"); // New binding wins

console.log("Instance1 name:", instance1); // undefined (global doesn't have name)
console.log("Instance2 name:", instance2.name); // "New Binding"
// ============================================
// RULE 1: DEFAULT BINDING
// By default 'this' is set to the global object (window in browser, global in Node.js)
// ============================================

function defaultBinding() {
console.log("Default Binding - this:", this);
console.log("Is this === window?", this === window); // true in browser
}

defaultBinding(); // 'this' points to global/window object

// In strict mode, default binding results in undefined
function strictDefaultBinding() {
"use strict";
console.log("Strict mode - this:", this); // undefined
}

strictDefaultBinding();

// ============================================
// RULE 2: NEW BINDING
// When function is called with 'new', 'this' is set to the newly created object
// ============================================

function Person(name, age) {
this.name = name;
this.age = age;
this.greet = function() {
console.log(`New Binding - Hello, I'm ${this.name}, age ${this.age}`);
console.log("'this' refers to:", this);
};
}

const person1 = new Person("John", 30);
const person2 = new Person("Jane", 25);

person1.greet(); // 'this' refers to person1 object
person2.greet(); // 'this' refers to person2 object

console.log("person1:", person1);
console.log("person2:", person2);

// ============================================
// RULE 3: IMPLICIT BINDING
// If function is called on an object (obj.method()), 'this' is the object
// ============================================

const user = {
name: "Alice",
age: 28,

showInfo: function() {
console.log("Implicit Binding - Name:", this.name);
console.log("Implicit Binding - Age:", this.age);
console.log("'this' refers to:", this);
},

// Nested object example
address: {
city: "New York",
showCity: function() {
console.log("Implicit Binding (nested) - City:", this.city);
console.log("'this' refers to address object:", this);
}
}
};

user.showInfo(); // 'this' refers to 'user' object
user.address.showCity(); // 'this' refers to 'address' object (immediate parent)

// Lost implicit binding example
const lostBinding = user.showInfo;
lostBinding(); // 'this' falls back to default binding (global/window)

// ============================================
// RULE 4: EXPLICIT BINDING
// Using call(), apply(), or bind() to explicitly set 'this'
// ============================================

const animal = {
name: "Dog",
sound: "Woof"
};

const cat = {
name: "Cat",
sound: "Meow"
};

function makeSound(volume, emotion) {
console.log(`Explicit Binding - ${this.name} says ${this.sound}!`);
console.log(`Volume: ${volume}, Emotion: ${emotion}`);
console.log("'this' explicitly set to:", this);
}

// Using call() - arguments passed individually
makeSound.call(animal, "loud", "excited");
makeSound.call(cat, "soft", "sleepy");

// Using apply() - arguments passed as array
makeSound.apply(animal, ["medium", "happy"]);
makeSound.apply(cat, ["quiet", "content"]);

// Using bind() - creates new function with bound 'this'
const dogSound = makeSound.bind(animal);
const catSound = makeSound.bind(cat);

dogSound("very loud", "playful");
catSound("whisper", "sneaky");

// ============================================
// BINDING PRECEDENCE ORDER (highest to lowest):
// 1. Explicit binding (call, apply, bind)
// 2. New binding (new keyword)
// 3. Implicit binding (object method call)
// 4. Default binding (global object or undefined in strict mode)
// ============================================

// Precedence example: Explicit vs Implicit
const obj = {
name: "Object",
test: function() {
console.log("Precedence test - this.name:", this.name);
}
};

const anotherObj = { name: "Another Object" };

obj.test(); // Implicit binding - "Object"
obj.test.call(anotherObj); // Explicit binding wins - "Another Object"

// Precedence example: New vs Implicit
function Constructor(name) {
this.name = name;
}

const obj2 = {
createInstance: Constructor
};

const instance1 = obj2.createInstance("Implicit"); // Lost binding, 'this' = global
const instance2 = new obj2.createInstance("New Binding"); // New binding wins

console.log("Instance1 name:", instance1); // undefined (global doesn't have name)
console.log("Instance2 name:", instance2.name); // "New Binding"
  • bind function does not work on arrow functions. It executes only once, the first time it is called.
  • In react when we pass a callback from one component to another, the value of this is lost. This is because when we assign a function to a variable, it loses the context of the original object.
const obj = {
value: 42,
getValue: function() { return this.value; }
};

const extracted = obj.getValue;
extracted(); // undefined - 'this' determined at call time
const obj = {
value: 42,
getValue: function() { return this.value; }
};

const extracted = obj.getValue;
extracted(); // undefined - 'this' determined at call time

Prototypical Inheritance

Good artice on Prototypical Inheritance

  • Inheritance in javascript basically means linking objects to other objects.
  • Every function has a prototype property. Functions get a prototype property because they might be used as constructors, but regular objects don't need one because they're not meant to create other objects.
  • Only function declarations and function expressions have the prototype property.
>>> What has it:

function foo() {} βœ…
const bar = function() {} βœ…
const baz = new Function() βœ…

>>> What doesn't have it:

Arrow functions: const arrow = () => {} ❌
Bound functions: foo.bind() ❌
Built-in functions: Math.max, Array.isArray ❌
Regular objects: {}, [] ❌
Primitives: strings, numbers, booleans ❌
Exception: Some built-in constructors like Object, Array, Function have prototype properties, but most built-in functions don't.
>>> What has it:

function foo() {} βœ…
const bar = function() {} βœ…
const baz = new Function() βœ…

>>> What doesn't have it:

Arrow functions: const arrow = () => {} ❌
Bound functions: foo.bind() ❌
Built-in functions: Math.max, Array.isArray ❌
Regular objects: {}, [] ❌
Primitives: strings, numbers, booleans ❌
Exception: Some built-in constructors like Object, Array, Function have prototype properties, but most built-in functions don't.
  • JavaScript uses an inheritance model called β€œdifferential inheritance”. What that means is that methods aren’t copied from parent to child. Instead, children have an β€œinvisible link” back to their parent object.

Objects

  • Object.freeze() - prevents modification of existing property values and prevents the addition of new properties.
  • Object.seal() - prevents new properties from being added, but allows modification of existing properties.
  • Object.preventExtensions() - prevents new properties from being added, but allows modification of existing properties.

Asynchronous Javascript

  • Javascript code can be thought of in two ways - one of "Now" type and other of "Later" type. The "Now" type is the synchronous code that is executed immediately. The "Later" type is the asynchronous code that is executed later.
  • The event loop is how JavaScript achieves non-blocking behavior. Long-running operations (like waiting for a network request via fetch) are handled by the environment APIs, freeing the Call Stack.
console.log('Program Start: Executing NOW'); // Part 1: Now

// Schedule a function to run 'later'
setTimeout(function taskForLater() {
// This function body is the 'later' part
console.log('Inside setTimeout: Executing LATER'); // Part 3: Later
}, 0); // We use 0ms delay

console.log('After setTimeout call: Also executing NOW'); // Part 2: Now

console.log('Program End: Executing NOW'); // Part 4: Now
console.log('Program Start: Executing NOW'); // Part 1: Now

// Schedule a function to run 'later'
setTimeout(function taskForLater() {
// This function body is the 'later' part
console.log('Inside setTimeout: Executing LATER'); // Part 3: Later
}, 0); // We use 0ms delay

console.log('After setTimeout call: Also executing NOW'); // Part 2: Now

console.log('Program End: Executing NOW'); // Part 4: Now
  • setTimeout is part of Web API. When the JS engine encounters setTimeout, it puts the callback function on the taskQueue and hands over the control to browser to execute the setTimetout function.
  • The JS event loop keep track of the taskQueue and the callStack. When the callStack is empty, the event loop takes the first task from the taskQueue and pushes it to the callStack.
  • A callback is a function that is passed as an argument to another function. The intention is that the receiving function will execute (β€œcall back”) your function at some point in the future – the β€œLater” part of our execution model.
  • When you pass your callback function (like taskForLater or handleUser) to another utility (like setTimeout or ajax), you are inverting the control. Instead of your code calling the function directly when you decide, you are handing control over to the other party, trusting them to execute your function correctly.
  • The Event Loop constantly cycles through these parts:
  • It checks if the Call Stack is empty. If it’s not, it keeps processing whatever is on the stack (β€œNow”).
  • Once the Call Stack is empty, it checks the Microtask Queue. If there are any tasks (jobs) waiting, it executes all of them, one by one, until the Microtask Queue is empty. If processing a microtask adds more microtasks, those are also processed in the same cycle.
  • Only when the Microtask Queue is empty does it check the Task Queue (Macrotask Queue).
  • If the Task Queue has a task, it takes the oldest one, pushes its callback function onto the (now empty) Call Stack, and executes it (β€œLater”).
  • (In browsers) After processing microtasks and potentially one macrotask, the loop may perform rendering updates if needed.
  • The loop then repeats.

async/await

  • A Promise isn’t just a callback mechanism; it’s a placeholder for a future value.
  • Both async and await are keywords in javascript.
  • When we add async keyword to a function, it does two things:
    • It makes the function return a promise. If a function returns a value, it is first wrapped in a resolved promise and then returned. If a functioin throws an error, it is wrapped in a rejected promise and then thrown.
    • It enables the use of await keyword in the function.
  • await keyword can only be used inside an async function.
  • await keyword makes the function wait for the result of the promise. If the promise is resolved, the result is returned. If the promise is rejected, the error is thrown.
  • Both async and await make the code look synchronous. It makes the code easier to debug and read.

Currying

  • Currying refers to the process of taking a function with n arguments and transforming it into n functions that each take a single argument. It essentially creates a chain of partially applied functions that eventually resolves with a value.
  • Here's a code snippet of a curry function which takes a function with n arguments and transforms it into n functions that each take a single argument.
function curry(fn) {
// Get the arity (number of expected arguments) of the original function
const arity = fn.length;

// This inner function will accumulate arguments
return function curried(...args) {
// If enough arguments have been accumulated, execute the original function
if (args.length >= arity) {
return fn(...args);
} else {
// Otherwise, return a new function that expects more arguments
return function(...nextArgs) {
return curried(...args, ...nextArgs);
};
}
};
}
function curry(fn) {
// Get the arity (number of expected arguments) of the original function
const arity = fn.length;

// This inner function will accumulate arguments
return function curried(...args) {
// If enough arguments have been accumulated, execute the original function
if (args.length >= arity) {
return fn(...args);
} else {
// Otherwise, return a new function that expects more arguments
return function(...nextArgs) {
return curried(...args, ...nextArgs);
};
}
};
}
  • Explanation of the code:
curry(fn):
This is the main curry function that takes the original function fn as an argument.

fn.length:
This property of a function in JavaScript returns its arity, which is the number of formally declared parameters it expects. This is crucial for knowing when to finally execute the original function.

curried(...args):
This is the function returned by curry. It's a higher-order function that will accumulate arguments. The ...args uses the rest parameter syntax to collect all arguments passed to curried into an array.

if (args.length >= arity):
This condition checks if the number of accumulated arguments (args.length) is equal to or greater than the original function's expected arity.
If true, it means all necessary arguments have been provided, so fn(...args) is called to execute the original function with all collected arguments.
If false, it means more arguments are needed.

return function(...nextArgs) { ... }:
If not enough arguments are present, a new anonymous function is returned. This function takes ...nextArgs (any new arguments provided in the next call).

return curried(...args, ...nextArgs);:
Inside the returned anonymous function, the curried function is called recursively. This time, it's called with the previously accumulated args combined with the newly provided nextArgs. This process continues until args.length >= arity is met.
curry(fn):
This is the main curry function that takes the original function fn as an argument.

fn.length:
This property of a function in JavaScript returns its arity, which is the number of formally declared parameters it expects. This is crucial for knowing when to finally execute the original function.

curried(...args):
This is the function returned by curry. It's a higher-order function that will accumulate arguments. The ...args uses the rest parameter syntax to collect all arguments passed to curried into an array.

if (args.length >= arity):
This condition checks if the number of accumulated arguments (args.length) is equal to or greater than the original function's expected arity.
If true, it means all necessary arguments have been provided, so fn(...args) is called to execute the original function with all collected arguments.
If false, it means more arguments are needed.

return function(...nextArgs) { ... }:
If not enough arguments are present, a new anonymous function is returned. This function takes ...nextArgs (any new arguments provided in the next call).

return curried(...args, ...nextArgs);:
Inside the returned anonymous function, the curried function is called recursively. This time, it's called with the previously accumulated args combined with the newly provided nextArgs. This process continues until args.length >= arity is met.