JavaScript NotesRohit’s interview study guide
0/136

Interview study guide

JavaScript, properly understood.

Everything from my JavaScript study doc, reorganised by topic, cleaned up, and filled in where the notes were only a heading or a screenshot. Every idea has code you can paste into a console.

Chapters
11
Topics
136
Code samples
201
Added beyond the doc
19
01Language FundamentalsTypes, variables, hoisting, equality and the small operators that trip people up in interviews.10 topics02Functions, Scope & thisDeclarations vs expressions, closures, the four rules of this, call/apply/bind, currying and composition.15 topics03Objects & PrototypesHow objects are created, how the prototype chain works, own vs inherited properties, descriptors, symbols and proxies.20 topics04Classes & OOPES classes, private fields, static blocks, inheritance, polymorphism, abstract classes, overloading and mixins.11 topics05Arrays & CollectionsThe array methods you'll use every day, which ones mutate, loops, and when to reach for Map, Set, WeakMap and WeakSet.14 topics06Copying & ImmutabilityShallow vs deep copies, every cloning technique and its catch, why React wants new objects, and freezing.9 topics07Async, the Event Loop & IterationPromises, async/await, how the event loop orders microtasks and macrotasks, concurrency, generators, workers and animation frames.19 topics08Browser, DOM & EventsScript loading, event propagation, delegation (and how React does it), debounce and throttle, passive listeners, storage and CSS specificity.11 topics09ModulesES modules vs CommonJS, named and default exports, live bindings, dynamic import, and why modules behave like singletons.7 topics10Design PatternsSingleton, factory, observer, decorator (the pattern and the @ syntax), dependency injection and object pooling — in idiomatic JavaScript.7 topics11Coding Challenges"Implement it yourself" questions with clean solutions: polyfills, caches, data structures and string utilities. Debounce and throttle are in Browser, DOM & Events; curry and compose are in Functions.13 topics

Added marks topics that weren’t in my original doc. Tick Learned on a topic to track what you’ve revised — it’s saved in this browser.

Chapter 01

Language Fundamentals

Types, variables, hoisting, equality and the small operators that trip people up in interviews.

#Types and dynamic typing

JavaScript is dynamically typed: a variable is not bound to a type, only its current value is. The same variable can hold a number now and a string later.

JavaScript
let x = 10;    // number
x = "Hello";   // now a string — perfectly legal
console.log(typeof x); // "string"

There are 7 primitive types and everything else is an object:

PrimitiveExampletypeof
string"hi""string"
number42, 3.14, NaN"number"
bigint10n"bigint"
booleantrue"boolean"
undefinedundefined"undefined"
nullnull"object" ⚠
symbolSymbol("id")"symbol"

Primitives are immutable and copied by value. Objects (including arrays and functions) are copied by reference — two variables can point at the same object.

JavaScript
let a = 1;
let b = a;   // copy of the value
b++;
console.log(a); // 1

const o1 = { n: 1 };
const o2 = o1;  // copy of the reference
o2.n++;
console.log(o1.n); // 2 — same object

#var, let and const

varletconst
ScopeFunctionBlock { }Block { }
HoistedYes, initialised to undefinedYes, but in the TDZYes, but in the TDZ
Re-declare in same scopeAllowedSyntaxErrorSyntaxError
Re-assignAllowedAllowedTypeError
Creates window property at top levelYesNoNo
JavaScript
function scopes() {
  if (true) {
    var a = 1;
    let b = 2;
    const c = 3;
  }
  console.log(a); // 1  — var ignores the block
  console.log(b); // ReferenceError: b is not defined
}

#Hoisting and the temporal dead zone

Hoisting: before any code runs, the engine registers every declaration in a scope. Declarations are "moved to the top"; initialisations are not.

JavaScript
console.log(x); // undefined — declaration hoisted, value not
var x = 5;

hoisted(); // works: function declarations are hoisted with their body
function hoisted() {
  console.log("I am hoisted");
}

let and const are hoisted too, but they sit in the temporal dead zone (TDZ) from the start of the block until the line that declares them. Touching them there throws.

JavaScript
console.log(y); // ReferenceError: Cannot access 'y' before initialization
let y = 5;
Predict the output
JavaScript
let x = 10;
function test() {
  console.log(x);
  let x = 20;
}
test();
Show answer

ReferenceError: Cannot access 'x' before initialization.

The inner let x is hoisted to the top of test, so every x inside test refers to the inner one — which is still in the TDZ when console.log runs. The outer x = 10 is shadowed for the whole function, not just after line 3.

#Variable shadowing

An inner scope can declare a variable with the same name as an outer one; the inner one shadows the outer one inside its block.

JavaScript
let x = 10;
{
  let x = 20;     // a different variable
  console.log(x); // 20
}
console.log(x);   // 10 — block scope keeps them apart

Illegal shadowing: you can shadow var with let in an inner block, but not let with var (because var would leak out of the block and clash).

JavaScript
let a = 1;
{
  var a = 2; // SyntaxError: Identifier 'a' has already been declared
}

#Equality: == vs ===

  • === strict equality — no type conversion. Same type and same value.
  • == loose equality — converts the operands to a common type first, then compares.
JavaScript
1 == "1";            // true  ("1" → 1)
0 == false;          // true  (false → 0)
"" == 0;             // true  ("" → 0)
null == undefined;   // true  (special rule)
null == 0;           // false (null only == undefined)

1 === "1";           // false — different types
0 === false;         // false
null === undefined;  // false

Edge cases worth knowing:

JavaScript
NaN === NaN;               // false — NaN is not equal to anything
Number.isNaN(NaN);         // true
Object.is(NaN, NaN);       // true
Object.is(0, -0);          // false (=== says true)
[] == ![];                 // true 🙃  ![] → false → 0, [] → "" → 0

#Truthy and falsy values

In a boolean context every value becomes true or false. There are exactly 8 falsy values; everything else is truthy.

JavaScript
false, 0, -0, 0n, "", null, undefined, NaN

#Optional chaining ?. and nullish coalescing ??

Optional chaining reads deeply nested properties safely: if anything before ?. is null or undefined, the whole expression short-circuits to undefined instead of throwing.

JavaScript
const user = { name: "Alice", address: { city: "Wonderland" } };

// Old way
const city1 = user && user.address && user.address.city;

// With optional chaining
const city2 = user?.address?.city;      // "Wonderland"
const zip = user?.address?.zip?.code;   // undefined, no error

It also works with dynamic keys and method calls:

JavaScript
const key = "city";
user.address?.[key];        // dynamic property
user.greet?.();             // call only if greet exists
callbacks.onDone?.(result); // common for optional callbacks

Nullish coalescing returns the right-hand side only when the left is null or undefined — unlike ||, which falls back on any falsy value.

JavaScript
const count = 0;
count || 10;  // 10  — 0 is falsy, oops
count ?? 10;  // 0   — 0 is a real value, keep it

const input = null;
const finalInput = input ?? "Default Value"; // "Default Value"

Logical assignment operators combine both ideas:

JavaScript
config.retries ??= 3;    // assign only if null/undefined
user.name ||= "Guest";   // assign if falsy
cache.hits &&= cache.hits + 1; // assign only if truthy

#Undeclared variables and strict mode

Assigning to a name that was never declared with var, let or const silently creates a global variable in sloppy mode. In strict mode it throws.

JavaScript
function foo() {
  x = 1; // sloppy mode: creates global x. Strict mode: ReferenceError
}
foo();
console.log(x); // 1
ValuetypeofAccessing it
Undeclared—"undefined"ReferenceError
Declared, not assignedundefined"undefined"undefined
nullintentional "no value""object"null

Turn on strict mode with "use strict"; at the top of a file or function. ES modules and classes are always strict, so in modern code you get it for free.

#Numbers: int or float?

Added

My notes said to check x % 1 === 0. That works, but there's a built-in that also handles the edge cases:

JavaScript
Number.isInteger(5);      // true
Number.isInteger(5.0);    // true — 5.0 is the same number as 5
Number.isInteger(5.5);    // false
Number.isInteger("5");    // false — no coercion
Number.isInteger(Infinity); // false (but Infinity % 1 is NaN, so the % trick is also fine)

const isFloat = (n) => Number.isFinite(n) && !Number.isInteger(n);

Other number facts interviewers like:

JavaScript
0.1 + 0.2 === 0.3;                 // false (0.30000000000000004)
Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON; // true — compare with a tolerance
Number.MAX_SAFE_INTEGER;           // 9007199254740991 (2^53 - 1)
2 ** 53 === 2 ** 53 + 1;           // true! use BigInt beyond this
9007199254740993n + 1n;            // 9007199254740994n
parseInt("08px");                  // 8 — parses until it can't
Number("08px");                    // NaN — all or nothing

#Type coercion cheat sheet

Added
JavaScript
"5" + 3;        // "53"   + with a string concatenates
"5" - 3;        // 2      other maths operators convert to number
"5" * "2";      // 10
true + 1;       // 2
[] + [];        // ""
[] + {};        // "[object Object]"
+"";            // 0
+"  42  ";      // 42
+"4 2";         // NaN
String(null);   // "null"
Number(null);   // 0
Number(undefined); // NaN
Chapter 02

Functions, Scope & this

Declarations vs expressions, closures, the four rules of this, call/apply/bind, currying and composition.

#Function declarations vs expressions vs arrows

The big practical difference between a declaration and an expression is hoisting.

Declaration — hoisted with its body

JavaScript
greet(); // "Hello!"

function greet() {
  console.log("Hello!");
}

Expression — only the variable is hoisted

JavaScript
greet(); // TypeError: greet is not a function

var greet = function () {
  console.log("Hello!");
};

With var, greet exists but is undefined at the call, so you get a TypeError. With let/const you'd get a ReferenceError (TDZ) instead.

Arrow functions are a shorter expression syntax with real behavioural differences:

Regular functionArrow function
Own thisYes — decided by how it's calledNo — uses this of the surrounding scope
arguments objectYesNo (use ...args)
Can be used with newYesNo — TypeError
Has prototypeYesNo
Good for object methodsYesUsually no
Good for callbacksNeeds bind if it uses thisYes
JavaScript
const square = (n) => n * n;          // implicit return
const makeUser = (name) => ({ name }); // wrap object literals in ( )

#Lexical scope

JavaScript uses lexical (static) scope: what a variable refers to is decided by where the code is written, not where it's called from. Each function can see its own variables, then its parent's, and so on up to the global scope — the scope chain.

JavaScript
const name = "global";

function outer() {
  const name = "outer";
  function inner() {
    console.log(name); // "outer" — looks up the chain where inner was *written*
  }
  return inner;
}

function caller() {
  const name = "caller";
  outer()(); // still "outer", not "caller"
}
caller();

#Closures

A closure is a function bundled together with the variables from the scope it was created in. The inner function keeps those variables alive even after the outer function has returned.

JavaScript
function makeCounter() {
  let count = 0; // private — nothing outside can touch it

  return {
    increment: () => ++count,
    decrement: () => --count,
    get value() {
      return count;
    },
  };
}

const counter = makeCounter();
counter.increment();
counter.increment();
console.log(counter.value); // 2
console.log(counter.count); // undefined — truly private

Each call to makeCounter() creates a new count, so two counters never share state.

Where closures show up in real code:

  • Private state — the module pattern, factory functions.
  • Function factories — add(5) returns a function that remembers 5 (currying).
  • Callbacks and event handlers that remember the value they were set up with.
  • Debounce / throttle / memoize — they all keep a timer or cache in a closure.
The classic loop question
JavaScript
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
Show answer

Prints 3 3 3.

var is function-scoped, so all three callbacks close over the same i, which is 3 by the time the timers fire. Change var to let and it prints 0 1 2: let creates a fresh binding for each loop iteration, so each callback captures its own i.

The pre-ES6 fix was an IIFE that captures the current value:

JavaScript
for (var i = 0; i < 3; i++) {
  ((j) => setTimeout(() => console.log(j), 0))(i);
}

#IIFE and the module pattern

An IIFE (Immediately Invoked Function Expression) runs as soon as it's defined. It creates a private scope so variables don't leak into the global namespace.

JavaScript
(function () {
  let a = 10;
  console.log(a); // 10
})();

console.log(a); // ReferenceError: a is not defined

// Arrow and async versions
(() => { /* ... */ })();
(async () => {
  const data = await fetch("/api").then((r) => r.json());
})();

The module pattern combines an IIFE with a closure to expose a public API and keep everything else private — this is how libraries were written before ES modules.

JavaScript
const BankModule = (function () {
  let balance = 0; // private

  function log(msg) { // private helper
    console.log(`[bank] ${msg}`);
  }

  return {
    deposit(amount) {
      balance += amount;
      log(`deposited ${amount}`);
    },
    getBalance() {
      return balance;
    },
  };
})();

BankModule.deposit(100);
BankModule.getBalance(); // 100
BankModule.balance;      // undefined

#How this works

this in a regular function is decided at call time, by how the function is called. Check the rules in this order:

  1. new binding — new Foo() → this is the brand-new object.
  2. Explicit binding — fn.call(obj), fn.apply(obj), fn.bind(obj) → this is obj.
  3. Implicit binding — obj.fn() → this is the object before the dot.
  4. Default binding — plain fn() → undefined in strict mode, globalThis in sloppy mode.

Arrow functions ignore all four rules and use this from the scope they were written in.

JavaScript
const user = {
  name: "Rohit",
  regular() {
    return this.name;
  },
  arrow: () => this?.name, // `this` of the module/global scope, not user
};

user.regular();        // "Rohit"   (implicit)
user.arrow();          // undefined (lexical)

const fn = user.regular;
fn();                  // undefined / TypeError — lost its object (default binding)
fn.call({ name: "X" }); // "X" (explicit)

#Lexical this with arrow functions

Arrow functions are perfect inside methods, where you want the callback to share the method's this:

JavaScript
const timer = {
  seconds: 0,
  start() {
    // Old way: const self = this; function () { self.seconds++ }
    setInterval(() => {
      this.seconds++; // `this` is timer, inherited from start()
    }, 1000);
  },
};
timer.start();

But they're the wrong choice as a method, or when a library sets this for you:

JavaScript
const counter = {
  count: 0,
  inc: () => this.count++, // ❌ `this` is not counter
};

button.addEventListener("click", function () {
  this.classList.toggle("on"); // ✅ `this` is the button
});
button.addEventListener("click", () => {
  this.classList.toggle("on"); // ❌ `this` is the outer scope
});

#call, apply and bind

All three let you choose what this is.

MethodCalls the function?ArgumentsReturns
fn.call(thisArg, a, b)ImmediatelyOne by oneFunction's result
fn.apply(thisArg, [a, b])ImmediatelyAs an arrayFunction's result
fn.bind(thisArg, a, b)NoOne by one (pre-filled)A new bound function
JavaScript
function introduce(greeting, punctuation) {
  return `${greeting}, I'm ${this.name}${punctuation}`;
}
const rohit = { name: "Rohit" };

introduce.call(rohit, "Hi", "!");      // "Hi, I'm Rohit!"
introduce.apply(rohit, ["Hello", "."]); // "Hello, I'm Rohit."

const sayHi = introduce.bind(rohit, "Hey");
sayHi("?");                             // "Hey, I'm Rohit?"

Mnemonic: Call = Commas, Apply = Array.

#Function borrowing

Use a method from one object on another object, without copying it.

JavaScript
const person1 = {
  firstName: "John",
  lastName: "Doe",
  fullName() {
    return `${this.firstName} ${this.lastName}`;
  },
};
const person2 = { firstName: "Jane", lastName: "Smith" };

person1.fullName.call(person2); // "Jane Smith"

Real-life uses:

JavaScript
// 1. A generic function shared by many objects
function calculateArea() {
  return this.length * this.width;
}
calculateArea.call({ length: 10, width: 5 }); // 50
calculateArea.call({ length: 7, width: 3 });  // 21

// 2. Array methods on array-like objects (arguments, NodeList)
function oldSchool() {
  return Array.prototype.slice.call(arguments); // today: Array.from(arguments) or [...arguments]
}

// 3. A reliable type check
Object.prototype.toString.call([]);   // "[object Array]"
Object.prototype.toString.call(null); // "[object Null]"

#The problem apply solved (and spread replaced)

Before ES2015 there was no way to pass an array as separate arguments — apply was the trick:

JavaScript
const nums = [5, 1, 9, 3];
Math.max.apply(null, nums); // 9
Math.max(...nums);          // 9 — spread does the same today

const arr1 = [1, 2];
const arr2 = [3, 4];
Array.prototype.push.apply(arr1, arr2); // same as arr1.push(...arr2)

#Real use cases for bind

JavaScript
// 1. Keeping `this` for callbacks
class Clock {
  constructor() {
    this.ticks = 0;
    this.tick = this.tick.bind(this); // bind once in the constructor
  }
  tick() {
    this.ticks++;
  }
  start() {
    setInterval(this.tick, 1000); // safe to pass around
  }
}

// 2. Partial application — pre-fill arguments
function log(level, message) {
  console.log(`[${level}] ${message}`);
}
const warn = log.bind(null, "WARN");
warn("Disk almost full"); // [WARN] Disk almost full

// 3. Event handlers that need the component instance
button.addEventListener("click", this.handleClick.bind(this));

#Higher-order functions

A higher-order function takes a function as an argument, returns a function, or both. map, filter, reduce, forEach, setTimeout and addEventListener are all higher-order functions.

JavaScript
function greet(name) {
  return function (message) {
    console.log(`${message}, ${name}`);
  };
}

const greetAlice = greet("Alice");
greetAlice("Hello");   // Hello, Alice
greetAlice("Goodbye"); // Goodbye, Alice

// Takes a function
function repeat(n, action) {
  for (let i = 0; i < n; i++) action(i);
}
repeat(3, console.log); // 0 1 2

#Currying

Currying turns a function of several arguments into a chain of functions that each take one argument.

JavaScript
function add(a) {
  return function (b) {
    return a + b;
  };
}

const addFive = add(5);
addFive(3);  // 8
add(2)(3);   // 5

const addArrow = (a) => (b) => a + b; // same thing

A generic curry helper is a popular interview question:

curry.js
function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    }
    return (...more) => curried.apply(this, [...args, ...more]);
  };
}

const volume = curry((l, w, h) => l * w * h);
volume(2)(3)(4);   // 24
volume(2, 3)(4);   // 24
volume(2)(3, 4);   // 24

Why bother? It makes specialised functions easy to create (const withTax = multiply(1.18)) and fits naturally with compose/pipe.

#Function composition: compose and pipe

compose(f, g, h)(x) means f(g(h(x))) — functions run right to left. pipe is the same idea left to right, which many people find easier to read.

JavaScript
const add1 = (n) => n + 1;
const double = (n) => n * 2;
const subtract10 = (n) => n - 10;

const compose = (...fns) => (x) => fns.reduceRight((acc, fn) => fn(acc), x);
const pipe = (...fns) => (x) => fns.reduce((acc, fn) => fn(acc), x);

compose(subtract10, double, add1)(3); // (3 + 1) * 2 - 10 = -2
pipe(add1, double, subtract10)(3);    // same: -2

#Rest parameters and spread

They use the same ... syntax but do opposite jobs: rest collects many values into an array, spread expands an iterable into individual values.

JavaScript
// Rest: gather arguments
function sum(...nums) {
  return nums.reduce((acc, n) => acc + n, 0);
}
sum(1, 2, 3, 4); // 10

function tag(first, ...others) {} // rest must be the last parameter

// Spread: expand
const a = [1, 2];
const b = [3, 4];
const combined = [...a, ...b];          // [1, 2, 3, 4]
const copy = [...a];                    // shallow copy
const chars = [..."hey"];               // ["h", "e", "y"]
const merged = { ...defaults, ...options }; // later keys win
Math.max(...combined);                  // 4

#Destructuring

Unpack values from arrays and properties from objects into variables.

JavaScript
// Arrays — by position
const [first, second, ...rest] = [10, 20, 30, 40];
const [, , third] = [1, 2, 3];        // skip items
let x = 1, y = 2;
[x, y] = [y, x];                      // swap without a temp variable

// Objects — by name
const user = { id: 7, name: "Rohit", address: { city: "Toronto" } };
const { name, age = 30 } = user;           // default when undefined
const { name: userName } = user;           // rename
const { address: { city } } = user;        // nested
const { id, ...withoutId } = user;         // rest of the properties

// In parameters — very common in React
function render({ title, items = [] }) {
  return `${title}: ${items.length}`;
}
render({ title: "Todos" });
Chapter 03

Objects & Prototypes

How objects are created, how the prototype chain works, own vs inherited properties, descriptors, symbols and proxies.

#Ways to create objects

JavaScript
// 1. Object literal — the everyday way
const person = { firstName: "John", lastName: "Doe", age: 50 };

// 2. Object constructor — same result, more typing
const p2 = new Object();
p2.firstName = "John";

// 3. Object.create — choose the prototype explicitly
const personProto = {
  greet() {
    console.log(`Hello, my name is ${this.name} and I'm ${this.age}.`);
  },
};
const p3 = Object.create(personProto);
p3.name = "John";
p3.age = 30;
p3.greet(); // Hello, my name is John and I'm 30.

// 4. Constructor function + new
function Person(name) {
  this.name = name;
}
const p4 = new Person("Alice");

// 5. ES2015 class (sugar over 4)
class PersonClass {
  constructor(name) {
    this.name = name;
  }
}

// 6. Factory function — no `new`, no `this`
const createPerson = (name) => ({ name, greet: () => `Hi ${name}` });

#Functions inside objects: two ways

JavaScript
const obj = {
  // 1. Method shorthand — has its own `this`, can use `super`
  greet() {
    return `Hi ${this.name}`;
  },
  // 2. Property holding a function
  greet2: function () {
    return `Hi ${this.name}`;
  },
  // (an arrow function here would NOT get `this` — see the `this` chapter)
  name: "Rohit",
};

Shorthand methods are the modern default. One subtle difference: shorthand methods can't be used with new and can call super.method() when the object has a prototype.

#Computed property names

Square brackets in an object literal make the key an expression that's evaluated to produce the property name.

JavaScript
const field = "email";
const id = 3;

const form = {
  [field]: "a@b.com",          // email: "a@b.com"
  [`item_${id}`]: true,        // item_3: true
  [Symbol.iterator]: function* () {}, // symbol keys must use brackets
};

// Handy for updating state by input name
const update = (state, e) => ({ ...state, [e.target.name]: e.target.value });

#The prototype chain

Every object has a hidden link, [[Prototype]], to another object (or null). When you read a property that the object doesn't have, JavaScript walks up this chain until it finds it or hits null. That's prototypal inheritance: objects inherit directly from other objects.

JavaScript
function Person(name) {
  this.name = name; // own property — one per instance
}
Person.prototype.greet = function () { // shared — one copy for all instances
  console.log("Hello, " + this.name);
};

const alice = new Person("Alice");
alice.greet();                  // found on Person.prototype
alice.toString();               // found on Object.prototype
alice.fly;                      // undefined — reached null

Object.getPrototypeOf(alice) === Person.prototype;      // true
Object.getPrototypeOf(Person.prototype) === Object.prototype; // true
Object.getPrototypeOf(Object.prototype);                // null
alice name: "Alice" own properties Person.prototype greet() constructor: Person Object.prototype toString() hasOwnProperty() null [[Prototype]] [[Prototype]] alice.toString() → not on alice → not on Person.prototype → found on Object.prototype
Property lookup walks right until it finds the key or reaches null.

#Methods go on .prototype

If you define a method inside the constructor, every instance gets its own copy. Put it on the prototype and all instances share one.

JavaScript
function Bad(name) {
  this.name = name;
  this.greet = function () {}; // new function per instance ❌
}
function Good(name) {
  this.name = name;
}
Good.prototype.greet = function () {}; // shared ✅

new Bad("a").greet === new Bad("b").greet;   // false
new Good("a").greet === new Good("b").greet; // true

ES classes do this automatically: methods in a class body go on the prototype.

#__proto__ vs prototype

They're easy to mix up:

What it isExists on
fn.prototypeThe object that will become the [[Prototype]] of instances created with new fn()Functions (not arrows)
obj.__proto__A (legacy) getter/setter for the object's own [[Prototype]] linkEvery object
JavaScript
function Dog() {}
const rex = new Dog();

rex.__proto__ === Dog.prototype;        // true
Dog.prototype.constructor === Dog;      // true
Dog.__proto__ === Function.prototype;   // true — Dog is itself a function object
rex.prototype;                          // undefined — instances don't have one

#What new actually does

Added
JavaScript
function myNew(Constructor, ...args) {
  // 1. Create an empty object linked to Constructor.prototype
  const obj = Object.create(Constructor.prototype);
  // 2. Run the constructor with `this` = the new object
  const result = Constructor.apply(obj, args);
  // 3. If the constructor returned an object, use that; otherwise use obj
  return result !== null && (typeof result === "object" || typeof result === "function") ? result : obj;
}

const a = myNew(Person, "Alice");
a instanceof Person; // true

instanceof simply checks whether Constructor.prototype appears anywhere in the object's prototype chain.

#Inheritance between constructor functions

Before class, inheritance took two steps: call the parent constructor for the own properties, and link the prototypes for the shared methods.

JavaScript
function Animal(name) {
  this.name = name;
}
Animal.prototype.eat = function () {
  return `${this.name} is eating`;
};

function Dog(name, breed) {
  Animal.call(this, name); // 1. inherit own properties ("super(name)")
  this.breed = breed;
}

// 2. inherit methods
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog; // repair the constructor pointer

Dog.prototype.bark = function () {
  return "Woof!";
};

const d = new Dog("Rex", "Lab");
d.eat();               // "Rex is eating"
d instanceof Animal;   // true

#Static methods on constructor functions

A "static" method lives on the constructor itself, not on instances.

JavaScript
function User(name) {
  this.name = name;
}
User.fromJSON = function (json) {   // static
  return new User(JSON.parse(json).name);
};
User.prototype.hello = function () { // instance
  return `Hi ${this.name}`;
};

const u = User.fromJSON('{"name":"Rohit"}');
u.hello();     // "Hi Rohit"
u.fromJSON;    // undefined — statics aren't inherited by instances

The class equivalent is static fromJSON(json) { … }.

#Shadowing prototype properties

Assigning a property on an instance creates an own property that hides (shadows) the prototype's one. The prototype is untouched.

JavaScript
function Car() {}
Car.prototype.wheels = 4;

const trike = new Car();
trike.wheels = 3;       // own property — shadows the prototype
const sedan = new Car();

trike.wheels;           // 3
sedan.wheels;           // 4
delete trike.wheels;
trike.wheels;           // 4 again — the prototype shows through

#Own vs inherited properties: Object.hasOwn vs in

CheckOwn propsInherited props
Object.hasOwn(obj, key)✅❌
obj.hasOwnProperty(key) (older)✅❌
key in obj✅✅
obj[key] !== undefinedUnreliable — the value might be undefined
JavaScript
const obj = { a: 1 };
Object.prototype.c = 3; // inherited (never do this in real code!)

Object.hasOwn(obj, "a"); // true
Object.hasOwn(obj, "c"); // false
"c" in obj;              // true — `in` walks the chain
"toString" in obj;       // true

#Iterating object properties with for...in

for...in loops over enumerable string keys, including inherited ones. Filter with Object.hasOwn if you only want the object's own keys.

JavaScript
const obj = { a: 1, b: 2, c: 3 };

for (const key in obj) {
  if (Object.hasOwn(obj, key)) {   // note: Object.hasOwn, not obj.hasOwn
    console.log(`${key}: ${obj[key]}`);
  }
}

Usually it's simpler to skip for...in and use Object.keys / Object.entries, which only return own enumerable keys:

JavaScript
for (const [key, value] of Object.entries(obj)) {
  console.log(key, value);
}

#Object.keys, values, entries and fromEntries

JavaScript
const prices = { apple: 1, banana: 0.5, cherry: 3 };

Object.keys(prices);    // ["apple", "banana", "cherry"]
Object.values(prices);  // [1, 0.5, 3]
Object.entries(prices); // [["apple", 1], ["banana", 0.5], ["cherry", 3]]

// fromEntries is the reverse — great for transforming objects
const doubled = Object.fromEntries(
  Object.entries(prices).map(([k, v]) => [k, v * 2]),
);
// { apple: 2, banana: 1, cherry: 6 }

const cheap = Object.fromEntries(Object.entries(prices).filter(([, v]) => v < 2));

// Also turns a Map or URLSearchParams into a plain object
Object.fromEntries(new URLSearchParams("a=1&b=2")); // { a: "1", b: "2" }

#Null-prototype objects

Object.create(null) makes an object with no prototype at all — no toString, no hasOwnProperty, nothing inherited.

JavaScript
const dict = Object.create(null);
dict.apple = 1;

"toString" in dict;      // false — a truly empty dictionary
dict.__proto__;          // undefined — even this key is safe to use
Object.hasOwn(dict, "apple"); // true (dict.hasOwnProperty would be a TypeError)

Why use one? As a pure lookup table where keys come from users (e.g. "constructor" or "__proto__") and must never collide with inherited properties. It's also one of the defences against prototype pollution. In most modern code a Map is the better choice.

#Property descriptors

Added

Every property has hidden attributes. Inspect them with Object.getOwnPropertyDescriptor and set them with Object.defineProperty.

JavaScript
const user = { name: "Rohit" };
Object.getOwnPropertyDescriptor(user, "name");
// { value: "Rohit", writable: true, enumerable: true, configurable: true }

Object.defineProperty(user, "id", {
  value: 42,
  writable: false,     // can't reassign
  enumerable: false,   // hidden from keys / for...in / JSON.stringify
  configurable: false, // can't delete or redefine
});

user.id = 7;           // silently ignored (TypeError in strict mode)
Object.keys(user);     // ["name"]
JSON.stringify(user);  // '{"name":"Rohit"}'
AttributeMeaningDefault with defineProperty
writableValue can be changedfalse
enumerableShows up in loops, Object.keys, spread, JSONfalse
configurableCan be deleted, or its attributes changedfalse
get / setAccessor functions (instead of value/writable)—

#Getters and setters

Accessors look like properties but run a function when read or written — useful for computed values and validation.

JavaScript
const account = {
  _balance: 0,
  get balance() {
    return `$${this._balance.toFixed(2)}`;
  },
  set balance(value) {
    if (value < 0) throw new RangeError("Balance can't be negative");
    this._balance = value;
  },
};

account.balance = 50;    // calls the setter
account.balance;         // "$50.00" — calls the getter (no parentheses)
account.balance = -1;    // RangeError

class Temperature {
  #celsius = 0;
  get fahrenheit() {
    return this.#celsius * 1.8 + 32;
  }
  set fahrenheit(f) {
    this.#celsius = (f - 32) / 1.8;
  }
}

#Symbols

A Symbol is a primitive that's guaranteed unique. Two symbols with the same description are still different.

JavaScript
const id1 = Symbol("id");
const id2 = Symbol("id");
id1 === id2; // false

const mySymbol = Symbol("privateProperty");
const obj = { name: "John", [mySymbol]: 42 };

Object.keys(obj);                     // ["name"] — symbols are skipped
JSON.stringify(obj);                  // '{"name":"John"}'
obj[mySymbol];                        // 42
Object.getOwnPropertySymbols(obj);    // [Symbol(privateProperty)]
Reflect.ownKeys(obj);                 // ["name", Symbol(privateProperty)]

Symbol properties are not enumerable by for...in, Object.keys or JSON, which makes them good for metadata and "internal" state that won't collide with normal keys. They are not truly private, though — getOwnPropertySymbols reveals them. For real privacy use #private fields.

Global symbols: Symbol.for("app.id") returns the same symbol every time, across files and even iframes. Symbol.keyFor(sym) gives back the key.

JavaScript
Symbol.for("app.id") === Symbol.for("app.id"); // true

#Well-known (built-in) symbols

JavaScript uses special symbols as hooks that let your objects plug into language features.

SymbolLets you customise
Symbol.iteratorfor...of, spread, destructuring
Symbol.asyncIteratorfor await...of
Symbol.toPrimitiveConversion to number/string (+obj, template literals)
Symbol.toStringTagThe [object X] in Object.prototype.toString
Symbol.hasInstanceinstanceof
JavaScript
const money = {
  amount: 42,
  currency: "CAD",
  [Symbol.toPrimitive](hint) {
    return hint === "number" ? this.amount : `${this.amount} ${this.currency}`;
  },
  get [Symbol.toStringTag]() {
    return "Money";
  },
};

+money;                               // 42
`${money}`;                           // "42 CAD"
Object.prototype.toString.call(money); // "[object Money]"

Symbol.iterator is covered in detail in Iterators and the iteration protocol.

#Proxy and Reflect

A Proxy wraps an object and lets you intercept operations on it — reads, writes, deletes, in, function calls — through traps. Reflect has a matching method for every trap, which you use to perform the default behaviour.

JavaScript
const target = { name: "Rohit" };

const logged = new Proxy(target, {
  get(obj, prop, receiver) {
    console.log(`read ${String(prop)}`);
    return Reflect.get(obj, prop, receiver);
  },
});

logged.name; // logs "read name", returns "Rohit"

Validation with a Proxy

JavaScript
function withValidation(obj) {
  return new Proxy(obj, {
    set(target, prop, value) {
      if (prop === "age" && (!Number.isInteger(value) || value < 0)) {
        throw new TypeError("age must be a non-negative integer");
      }
      return Reflect.set(target, prop, value); // must return true for success
    },
  });
}

const person = withValidation({});
person.age = 30;    // ok
person.age = -5;    // TypeError
person.age = "30";  // TypeError

Other uses: default values for missing keys, negative array indexes (arr[-1]), read-only views, change tracking. Vue 3's reactivity system and libraries like Immer and MobX are built on Proxies.

#Avoiding prototype pollution

Prototype pollution is a security bug where attacker-controlled keys like __proto__, constructor or prototype end up modifying Object.prototype — which then affects every object in the app.

JavaScript
// A naive deep merge
function merge(target, source) {
  for (const key in source) {
    if (typeof source[key] === "object" && source[key] !== null) {
      target[key] ??= {};
      merge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

const payload = JSON.parse('{"__proto__": {"isAdmin": true}}');
merge({}, payload);

({}).isAdmin; // true 😱 — every object is now "admin"

How to defend:

  • Skip dangerous keys when merging or setting by path: if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
  • Use Map for user-keyed data, or Object.create(null) objects.
  • Check with Object.hasOwn rather than in or a truthy check.
  • Validate input with a schema (zod, Joi, JSON Schema) before merging it.
  • As a last resort, Object.freeze(Object.prototype) at startup (can break old libraries).
Chapter 04

Classes & OOP

ES classes, private fields, static blocks, inheritance, polymorphism, abstract classes, overloading and mixins.

#Classes in JavaScript

A class is syntactic sugar over constructor functions and prototypes — the prototype chain underneath is exactly the same. But classes add a few real rules: they must be called with new, their bodies are always strict mode, and they're in the TDZ until declared (not hoisted like function declarations).

JavaScript
class Person {
  // Public instance field — one per instance
  species = "human";

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

  // Method — goes on Person.prototype, shared by all instances
  greet() {
    return `Hello, my name is ${this.name} and I'm ${this.age}.`;
  }

  // Static — lives on the class itself
  static compare(a, b) {
    return a.age - b.age;
  }
}

const john = new Person("John", 30);
john.greet();
typeof Person;                                 // "function"
Object.getPrototypeOf(john) === Person.prototype; // true
Person(); // TypeError: Class constructor Person cannot be invoked without 'new'

#Class fields vs prototype methods (the gotcha)

How you write a method changes where it lives:

JavaScript
class Person {
  constructor(name) {
    this.name = name;
  }
  greetA() {}               // on Person.prototype — shared
  greetB = function () {};  // class field — a new function on EVERY instance
  greetC = () => this.name; // class field arrow — per instance, `this` locked
}

const a = new Person("a");
const b = new Person("b");
a.greetA === b.greetA;           // true
a.greetB === b.greetB;           // false
Object.hasOwn(a, "greetB");      // true — own property, not inherited

#Private fields with #

Fields and methods starting with # are truly private: they can't be read, written or even detected from outside the class body. This is enforced by the language, unlike the old _underscore convention.

JavaScript
class BankAccount {
  #balance; // must be declared up front

  constructor(owner, balance) {
    this.owner = owner;
    this.#balance = balance;
  }

  getBalance() {
    return `The balance for ${this.owner} is $${this.#balance}`;
  }

  deposit(amount) {
    this.#validate(amount);
    this.#balance += amount;
    return `Deposited $${amount}. New balance is $${this.#balance}`;
  }

  withdraw(amount) {
    this.#validate(amount);
    if (amount > this.#balance) return "Insufficient balance.";
    this.#balance -= amount;
    return `Withdrew $${amount}. Remaining balance is $${this.#balance}`;
  }

  #validate(amount) { // private method
    if (amount <= 0) throw new RangeError("Amount must be positive");
  }

  static isAccount(obj) {
    return #balance in obj; // ergonomic brand check
  }
}

const account = new BankAccount("Alice", 1000);
account.deposit(500);   // Deposited $500. New balance is $1500
account.withdraw(200);  // Withdrew $200. Remaining balance is $1300
account.balance;        // undefined
// account.#balance;    // SyntaxError: Private field '#balance' must be declared in an enclosing class

#Encapsulation recap

Encapsulation means hiding internal state and exposing only a controlled API. JavaScript has had several ways to do it:

TechniqueTruly private?Notes
_name conventionNoJust a hint to other developers
Closures (factory / module pattern)YesEach instance has its own copies of methods
WeakMap keyed by instanceYesThe pre-2022 class trick
Symbol keysNoHidden from keys/JSON but discoverable
#private fieldsYesThe modern answer — use this
JavaScript
// Closure-based privacy (works without classes)
function createAccount(initial) {
  let balance = initial;
  return {
    deposit: (n) => (balance += n),
    get balance() {
      return balance;
    },
  };
}

#Static members and static blocks

static properties and methods belong to the class, not to instances. Static initialisation blocks (ES2022) run once when the class is defined — useful for setup that needs several statements or access to private statics.

JavaScript
class Config {
  static #instances = 0;
  static defaults;

  static {
    // runs once, when the class is evaluated
    const env = globalThis.process?.env?.NODE_ENV ?? "development";
    Config.defaults = { env, debug: env !== "production" };
  }

  constructor() {
    Config.#instances++;
  }

  static get count() {
    return Config.#instances;
  }
}

Config.defaults; // { env: "development", debug: true }
new Config();
Config.count;    // 1

Static members are inherited by subclasses (class Sub extends Config {} → Sub.defaults works), because Sub's own prototype is Config.

#Inheritance with extends and super

JavaScript
class Animal {
  constructor(name) {
    this.name = name;
  }
  speak() {
    return `${this.name} makes a sound`;
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name);        // MUST be called before using `this`
    this.breed = breed;
  }
  speak() {
    return `${super.speak()} — Woof!`; // call the parent version
  }
}

new Dog("Rex", "Lab").speak(); // "Rex makes a sound — Woof!"

You can also extend built-ins:

JavaScript
class ValidationError extends Error {
  constructor(field, message) {
    super(message);
    this.name = "ValidationError";
    this.field = field;
  }
}

#Polymorphism and method overriding

Polymorphism: code written against a parent type works with any subclass, and each subclass can override a method to behave differently.

JavaScript
class Shape {
  area() {
    return 0;
  }
  describe() {
    return `${this.constructor.name} with area ${this.area().toFixed(2)}`;
  }
}

class Circle extends Shape {
  constructor(r) {
    super();
    this.r = r;
  }
  area() {
    return Math.PI * this.r ** 2;
  }
}

class Square extends Shape {
  constructor(s) {
    super();
    this.s = s;
  }
  area() {
    return this.s ** 2;
  }
}

[new Circle(1), new Square(2)].map((s) => s.describe());
// ["Circle with area 3.14", "Square with area 4.00"]

#Method overloading (emulated)

JavaScript doesn't support overloading like Java or C++: a second method with the same name just replaces the first. (TypeScript has overload signatures, but there's still only one implementation.) You emulate it by inspecting the arguments:

JavaScript
class Calculator {
  add(...args) {
    if (args.length === 1 && Array.isArray(args[0])) {
      return args[0].reduce((a, b) => a + b, 0);       // add([1, 2, 3])
    }
    if (args.every((a) => typeof a === "number")) {
      return args.reduce((a, b) => a + b, 0);          // add(1, 2, 3)
    }
    if (args.every((a) => typeof a === "string")) {
      return args.join("");                             // add("a", "b")
    }
    throw new TypeError("Unsupported arguments");
  }
}

const c = new Calculator();
c.add(1, 2);       // 3
c.add([1, 2, 3]);  // 6
c.add("a", "b");   // "ab"

An options object is often cleaner than overloading: createUser({ name, email, role = "user" }).

#Abstract classes with new.target

JavaScript has no abstract keyword, but new.target tells a constructor which class new was called on. If it's the base class itself, refuse.

JavaScript
class Shape {
  constructor() {
    if (new.target === Shape) {
      throw new TypeError("Shape is abstract — extend it instead");
    }
    if (this.area === Shape.prototype.area) {
      throw new TypeError(`${new.target.name} must implement area()`);
    }
  }
  area() {
    throw new Error("not implemented");
  }
}

class Rect extends Shape {
  constructor(w, h) {
    super();
    this.w = w;
    this.h = h;
  }
  area() {
    return this.w * this.h;
  }
}

new Rect(2, 3).area(); // 6
new Shape();           // TypeError: Shape is abstract
class Bad extends Shape {}
new Bad();             // TypeError: Bad must implement area()

new.target on its own

JavaScript
function Person(name) {
  if (!new.target) {
    return new Person(name); // called without new? fix it
  }
  this.name = name;
}

Person("Rohit").name; // "Rohit" — works with or without `new`

new.target is undefined in a normal function call and refers to the constructor (or subclass) when called with new.

#Mixins with Object.assign

A mixin is a bag of methods you copy into a class to share behaviour without a parent/child relationship — handy because a class can only extend one parent.

JavaScript
const canSwim = {
  swim() {
    return `${this.name} is swimming`;
  },
};
const canFly = {
  fly() {
    return `${this.name} is flying`;
  },
};

class Duck {
  constructor(name) {
    this.name = name;
  }
}
Object.assign(Duck.prototype, canSwim, canFly);

const d = new Duck("Donald");
d.swim(); // "Donald is swimming"
d.fly();  // "Donald is flying"

A more composable style uses subclass factories:

JavaScript
const Serializable = (Base) =>
  class extends Base {
    serialize() {
      return JSON.stringify(this);
    }
  };
const Timestamped = (Base) =>
  class extends Base {
    createdAt = Date.now();
  };

class User {}
class SmartUser extends Serializable(Timestamped(User)) {}

#Composition over inheritance

Deep inheritance trees get brittle: a change in a base class ripples everywhere, and real things rarely fit a single hierarchy (the classic "FlyingFish extends Fish or Bird?" problem). Composition builds objects from small, independent behaviours instead — "has-a" rather than "is-a".

JavaScript
// Behaviours as small functions that work on shared state
const canEat = (state) => ({
  eat: (food) => `${state.name} eats ${food}`,
});
const canWalk = (state) => ({
  walk: () => `${state.name} walks`,
});
const canSwim = (state) => ({
  swim: () => `${state.name} swims`,
});

const createDog = (name) => {
  const state = { name };
  return { ...canEat(state), ...canWalk(state), ...canSwim(state) };
};
const createFish = (name) => {
  const state = { name };
  return { ...canEat(state), ...canSwim(state) };
};

createDog("Rex").walk();   // "Rex walks"
createFish("Nemo").swim(); // "Nemo swims"
Chapter 05

Arrays & Collections

The array methods you'll use every day, which ones mutate, loops, and when to reach for Map, Set, WeakMap and WeakSet.

#Array cheat sheet

The most important column: does it change the original array?

MethodReturnsMutates?
push(x) / unshift(x)New length✅ adds to end / start
pop() / shift()Removed item✅ removes from end / start
splice(i, n, ...items)Removed items✅
sort(fn) / reverse()The same array✅
fill(v) / copyWithin()The same array✅
map(fn)New array, same length❌
filter(fn)New array of matches❌
reduce(fn, init)Single value❌
slice(start, end)New sub-array❌
concat(...arrs)New array❌
flat(depth) / flatMap(fn)New array❌
toSorted / toReversed / toSpliced / withNew array❌ (ES2023)
find / findLastFirst / last matching item or undefined❌
findIndex / findLastIndexIndex or -1❌
indexOf(x) / includes(x)Index or -1 / boolean❌
some(fn) / every(fn)Boolean❌
forEach(fn)undefined❌
join(sep)String❌
at(i)Item (negative counts from end)❌

#map, filter and reduce

The three workhorse higher-order functions.

JavaScript
const products = [
  { name: "Laptop", price: 1200, inStock: true },
  { name: "Mouse", price: 25, inStock: false },
  { name: "Monitor", price: 300, inStock: true },
];

// map: transform every item → same length
const names = products.map((p) => p.name); // ["Laptop", "Mouse", "Monitor"]

// filter: keep items that pass → same or shorter
const available = products.filter((p) => p.inStock);

// reduce: boil the array down to one value
const total = products.reduce((sum, p) => sum + p.price, 0); // 1525

Chaining filter and map

JavaScript
const inStockNames = products
  .filter((p) => p.inStock)
  .map((p) => p.name.toUpperCase()); // ["LAPTOP", "MONITOR"]

reduce beyond sums

JavaScript
// Count occurrences
const fruits = ["apple", "banana", "apple", "cherry", "apple"];
const counts = fruits.reduce((acc, f) => {
  acc[f] = (acc[f] ?? 0) + 1;
  return acc;
}, {}); // { apple: 3, banana: 1, cherry: 1 }

// Index by id
const byName = products.reduce((acc, p) => ({ ...acc, [p.name]: p }), {});

// Max value
const priciest = products.reduce((max, p) => (p.price > max.price ? p : max));
Predict the output
JavaScript
console.log(["1", "2", "3"].map(parseInt));
Show answer

[1, NaN, NaN].

map passes three arguments: (value, index, array). So the calls are parseInt("1", 0) → 1 (radix 0 means "auto"), parseInt("2", 1) → NaN (radix 1 is invalid), parseInt("3", 2) → NaN ("3" isn't a binary digit). Use .map(Number) or .map((s) => parseInt(s, 10)).

#find vs filter

findfilter
ReturnsThe first matching itemAn array of all matches
Nothing foundundefined[]
Stops earlyYes, at the first matchNo, checks everything
JavaScript
const users = [
  { id: 1, name: "Rohit", admin: true },
  { id: 2, name: "Sam", admin: false },
  { id: 3, name: "Ana", admin: true },
];

users.find((u) => u.admin);    // { id: 1, name: "Rohit", admin: true }
users.filter((u) => u.admin);  // [Rohit, Ana]
users.find((u) => u.id === 9); // undefined

#findIndex, findLast and findLastIndex

JavaScript
const nums = [5, 12, 8, 130, 44];

nums.findIndex((n) => n > 10);     // 1   (12)
nums.findLast((n) => n > 10);      // 44
nums.findLastIndex((n) => n > 10); // 4
nums.findIndex((n) => n > 500);    // -1

// Typical use: update an item in place
const i = users.findIndex((u) => u.id === 2);
if (i !== -1) users[i] = { ...users[i], name: "Samuel" };

findLast / findLastIndex (ES2023) search from the end — handy for "the most recent message from this user" without reversing a copy first.

#some and every

JavaScript
const ages = [19, 22, 17, 30];

ages.some((a) => a < 18);   // true  — at least one minor
ages.every((a) => a >= 18); // false — not all adults

[].some(() => true);  // false
[].every(() => false); // true — "vacuous truth": no item fails

Both stop as soon as the answer is known (some at the first true, every at the first false), so they're also a way to "break" out of an iteration early.

#slice vs splice

slice(start, end) — copies

JavaScript
const a = [1, 2, 3, 4, 5];
a.slice(1, 3);  // [2, 3]
a.slice(-2);    // [4, 5]
a.slice();      // shallow copy
a;              // [1, 2, 3, 4, 5] unchanged

splice(start, deleteCount, ...items) — edits in place

JavaScript
const fish = ["parrot", "anemone", "blue", "trumpet", "sturgeon"];
const removed = fish.splice(2, 2);
// fish    → ["parrot", "anemone", "sturgeon"]
// removed → ["blue", "trumpet"]

fish.splice(1, 0, "clown"); // insert, delete nothing
// ["parrot", "clown", "anemone", "sturgeon"]

Mnemonic: splice has a p for "permanent" — it changes the array.

#Change-array-by-copy: toSorted, toReversed, toSpliced, with

ES2023 added non-mutating twins of the mutating methods. They're perfect for React state, where you must not mutate.

JavaScript
const scores = [30, 10, 20];

scores.toSorted((a, b) => a - b); // [10, 20, 30]
scores.toReversed();              // [20, 10, 30]
scores.toSpliced(1, 1);           // [30, 20]
scores.with(0, 99);               // [99, 10, 20] — replace one index
scores;                           // [30, 10, 20] — untouched

Why not delete arr[i]?

JavaScript
const arr = ["a", "b", "c"];
delete arr[1];
arr;        // ["a", <empty>, "c"] — leaves a hole
arr.length; // 3 — length doesn't change

// Remove properly
arr.splice(1, 1);              // mutating
const next = arr.toSpliced(1, 1); // non-mutating
const next2 = arr.filter((_, i) => i !== 1);

#Sorting gotchas

Added
JavaScript
[10, 1, 5, 100].sort();               // [1, 10, 100, 5] 😬 — default sort compares strings
[10, 1, 5, 100].sort((a, b) => a - b); // [1, 5, 10, 100]
[10, 1, 5, 100].sort((a, b) => b - a); // [100, 10, 5, 1]

// Strings: use localeCompare for proper alphabetical order
["résumé", "apple", "Zebra"].sort((a, b) => a.localeCompare(b));

// By a property, then by another
people.sort((a, b) => a.lastName.localeCompare(b.lastName) || a.age - b.age);

The comparator returns a negative number if a comes first, positive if b comes first, 0 if equal. Since ES2019 sort is guaranteed stable (equal items keep their order).

#flat and flatMap

JavaScript
[1, [2, [3, [4]]]].flat();          // [1, 2, [3, [4]]]  — depth 1 by default
[1, [2, [3, [4]]]].flat(2);         // [1, 2, 3, [4]]
[1, [2, [3, [4]]]].flat(Infinity);  // [1, 2, 3, 4]

// flatMap = map then flat(1) — map one item to zero or many
const sentences = ["hello world", "hi there"];
sentences.flatMap((s) => s.split(" ")); // ["hello", "world", "hi", "there"]

// Filter and map in one pass: return [] to drop an item
[1, 2, 3, 4].flatMap((n) => (n % 2 ? [] : [n * 10])); // [20, 40]

#Array.from and creating arrays

Array.from(iterableOrArrayLike, mapFn?) builds a real array from anything iterable (Set, Map, string, NodeList) or array-like ({ length: n }, arguments).

JavaScript
Array.from("abc");                       // ["a", "b", "c"]
Array.from(new Set([1, 1, 2]));          // [1, 2]
Array.from(document.querySelectorAll("li")); // NodeList → Array

// Create an array and fill it with index + 1
Array.from({ length: 5 }, (_, i) => i + 1); // [1, 2, 3, 4, 5]
[...Array(5).keys()].map((i) => i + 1);     // same result
Array(5).fill(0);                            // [0, 0, 0, 0, 0]

Creating a 2-D array

JavaScript
// ✅ Each row is a new array
const grid = Array.from({ length: 3 }, () => Array(3).fill(0));
grid[0][0] = 1;
// [[1,0,0],[0,0,0],[0,0,0]]

// ❌ fill() puts the SAME array object in every row
const bad = Array(3).fill(Array(3).fill(0));
bad[0][0] = 1;
// [[1,0,0],[1,0,0],[1,0,0]] — all rows changed!

#Loops over arrays

LoopGives youbreak / continueawait inside worksUse for
for (let i = 0; …)Index✅✅Index maths, going backwards
for...ofValues✅✅ (sequential)Default choice for arrays & iterables
forEachValue, index❌❌ (doesn't wait)Simple side effects
for...inKeys (strings!)✅✅Objects, not arrays
JavaScript
const numbers = [1, 2, 3, 4, 5];

for (const n of numbers) console.log(n);
numbers.forEach((n, i) => console.log(i, n));
for (const [i, n] of numbers.entries()) console.log(i, n);

for...of vs for...in

JavaScript
const arr = ["apple", "banana", "cherry"];
arr.extra = "oops";

for (const fruit of arr) console.log(fruit); // apple banana cherry (values)
for (const key in arr) console.log(key);     // "0" "1" "2" "extra" (keys, as strings!)

const obj = { name: "John", age: 30 };
for (const key in obj) console.log(key, obj[key]); // fine for objects
for (const x of obj) {}                            // TypeError: obj is not iterable

for...of works on every iterable: arrays, strings, Maps, Sets, NodeLists, generators. forEach only exists on arrays, Maps, Sets and NodeLists — not on strings or plain objects.

#Unique values with Set

JavaScript
function uniqueArray(array) {
  return [...new Set(array)]; // or Array.from(new Set(array))
}
uniqueArray([1, 1, 2, 3, 3]); // [1, 2, 3]

A Set stores unique values (compared like ===, except NaN equals NaN) and keeps insertion order.

JavaScript
const tags = new Set(["js", "ts"]);
tags.add("js");        // ignored — already there
tags.has("ts");        // true — O(1), much faster than array.includes on big lists
tags.delete("ts");
tags.size;             // 1

// Set operations (ES2025 — modern browsers and Node 22+)
const a = new Set([1, 2, 3]);
const b = new Set([2, 3, 4]);
a.union(b);        // {1, 2, 3, 4}
a.intersection(b); // {2, 3}
a.difference(b);   // {1}

// Older equivalent
const intersection = new Set([...a].filter((x) => b.has(x)));

#Map deep dive

A Map is a key-value collection where keys can be any type — objects, functions, numbers — and insertion order is preserved.

JavaScript
const person = new Map([
  ["name", "John"],
  ["age", 30],
]);

person.set("occupation", "Developer");
person.get("name");   // "John"
person.has("age");    // true
person.delete("age");
person.size;          // 2

// Object keys — impossible with plain objects
const meta = new Map();
const btn = document.querySelector("button");
meta.set(btn, { clicks: 0 });
meta.get(btn).clicks++;
MapPlain object
Key typesAnythingStrings and symbols only
OrderInsertion orderMostly insertion (integer keys first)
Sizemap.sizeObject.keys(obj).length
Iterable directlyYesNo (use Object.entries)
Inherited keysNoneHas a prototype (toString, …) unless Object.create(null)
Frequent add/removeOptimised for itSlower
JSON supportNo — convert firstYes

Iterating a Map

JavaScript
for (const [key, value] of person) console.log(key, value);
person.forEach((value, key) => console.log(key, value)); // note: value first

[...person.keys()];     // ["name", "occupation"]
[...person.values()];   // ["John", "Developer"]
[...person.entries()];  // [["name","John"], ["occupation","Developer"]]

// Convert both ways
const obj = Object.fromEntries(person);
const map = new Map(Object.entries(obj));
JSON.stringify([...person]); // serialise as an array of pairs

#WeakMap and WeakSet

Added

Like Map/Set, but keys must be objects, and they're held weakly: if nothing else references the key object, it can be garbage-collected and the entry disappears automatically.

JavaScript
// Private data per instance without memory leaks
const privateData = new WeakMap();

class User {
  constructor(name, password) {
    privateData.set(this, { password });
    this.name = name;
  }
  checkPassword(pw) {
    return privateData.get(this).password === pw;
  }
}

// Cache results per object; entries vanish when the object does
const cache = new WeakMap();
function expensive(obj) {
  if (!cache.has(obj)) cache.set(obj, heavyComputation(obj));
  return cache.get(obj);
}

// WeakSet: "have I seen this object?" — e.g. avoid processing a DOM node twice
const processed = new WeakSet();
function process(node) {
  if (processed.has(node)) return;
  processed.add(node);
}

Trade-off: because entries can vanish at any time, Weak collections are not iterable and have no size — only get/set/has/delete (or add/has/delete).

Chapter 06

Copying & Immutability

Shallow vs deep copies, every cloning technique and its catch, why React wants new objects, and freezing.

#Shallow vs deep copy

  • A shallow copy creates a new top-level object, but nested objects are still shared with the original.
  • A deep copy recursively copies everything, so nothing is shared.
JavaScript
const original = { toy: "Car", smallBox: { toy: "Doll" } };

const shallow = { ...original };
shallow.toy = "Ball";              // fine — top level is separate
shallow.smallBox.toy = "Robot";    // ⚠ changes original too!

original.toy;          // "Car"
original.smallBox.toy; // "Robot"

Think of a shallow copy as a new box with the same inner boxes inside; a deep copy builds new inner boxes too.

#Shallow cloning techniques

JavaScript
// Objects
const a = { ...obj };
const b = Object.assign({}, obj);

// Arrays
const c = [...arr];
const d = arr.slice();
const e = Array.from(arr);
const f = arr.concat();

All of these copy own enumerable properties one level deep. Spread and Object.assign behave almost the same; the difference is that Object.assign sets properties on the target (so it triggers setters), while spread defines new ones.

#Why React likes shallow copies

React decides whether to re-render by comparing state with Object.is — a reference check, not a deep comparison. If you mutate an object in place, the reference is the same and React thinks nothing changed.

React
// ❌ Mutation: same reference, React may skip the re-render
user.address.city = "Toronto";
setUser(user);

// ✅ New objects along the path that changed; everything else is shared
setUser({
  ...user,
  address: { ...user.address, city: "Toronto" },
});

// Arrays
setTodos([...todos, newTodo]);                                  // add
setTodos(todos.filter((t) => t.id !== id));                     // remove
setTodos(todos.map((t) => (t.id === id ? { ...t, done: true } : t))); // update

This is called structural sharing: you copy only the objects on the path to the change and reuse the rest. It's cheap (no deep clone) and makes "did this change?" a fast === check — which is also what React.memo, useMemo dependencies and Redux selectors rely on.

#Deep clone with JSON (and its drawbacks)

JavaScript
const deepClone = JSON.parse(JSON.stringify(original));
// Step 1: stringify takes a "picture" of the object as text
// Step 2: parse builds a brand-new object from that picture

It's quick, but JSON can only represent strings, numbers, booleans, null, arrays and plain objects. Everything else is lost or mangled:

ValueAfter a JSON round trip
Functions, undefined, symbolsDropped from objects (become null in arrays)
DateA string
Map, Set{}
NaN, Infinitynull
RegExp{}
Class instancesPlain objects — prototype lost
Circular referencesTypeError: Converting circular structure to JSON
BigIntTypeError

#structuredClone — the better built-in

structuredClone(value) is the platform's deep-clone function (browsers and Node 17+). It uses the same algorithm as postMessage.

JavaScript
const original = {
  date: new Date(),
  tags: new Set(["a"]),
  nested: { list: [1, 2, 3] },
};
original.self = original; // circular!

const copy = structuredClone(original);
copy.date instanceof Date;   // true
copy.tags.has("a");          // true
copy.self === copy;          // true — circular refs preserved
copy.nested !== original.nested; // true

It can't clone functions or DOM nodes (throws DataCloneError), and like JSON it doesn't keep class prototypes or getters — a cloned class instance becomes a plain object.

#Recursive deep clone

A hand-written version is a common interview question. Here's my original, with the bug fixed:

deepClone.js
export default function deepClone(value) {
  // typeof null === "object", so null must be checked first
  if (value === null || typeof value !== "object") {
    return value;
  }

  if (Array.isArray(value)) {
    return value.map((item) => deepClone(item));
  }

  return Object.fromEntries(
    Object.entries(value).map(([key, val]) => [key, deepClone(val)]),
  );
}

An interview-ready version that also handles dates, maps, sets and circular references:

deepClone-advanced.js
function deepClone(value, seen = new WeakMap()) {
  if (value === null || typeof value !== "object") return value;
  if (seen.has(value)) return seen.get(value); // circular reference

  if (value instanceof Date) return new Date(value);
  if (value instanceof RegExp) return new RegExp(value.source, value.flags);

  if (value instanceof Map) {
    const copy = new Map();
    seen.set(value, copy);
    value.forEach((v, k) => copy.set(deepClone(k, seen), deepClone(v, seen)));
    return copy;
  }

  if (value instanceof Set) {
    const copy = new Set();
    seen.set(value, copy);
    value.forEach((v) => copy.add(deepClone(v, seen)));
    return copy;
  }

  // Keep the prototype so class instances stay class instances
  const copy = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value));
  seen.set(value, copy);
  for (const key of Reflect.ownKeys(value)) {   // includes symbol keys
    copy[key] = deepClone(value[key], seen);
  }
  return copy;
}

#Which cloning method should I use?

NeedUse
One level, e.g. React state updateSpread { ...obj } / [...arr]
Deep copy of plain data, Dates, Maps, Sets, cyclesstructuredClone
Deep copy that must keep class prototypes / functionslodash.cloneDeep or a custom function
Quick deep copy of pure JSON dataJSON.parse(JSON.stringify(x))

Lodash cloneDeep has been the battle-tested choice for years; it handles prototypes, typed arrays, Maps/Sets and cycles. With structuredClone built in, most apps no longer need it.

#Object.freeze vs Object.seal vs preventExtensions

Add propsDelete propsChange valuesCheck with
Object.preventExtensions❌✅✅Object.isExtensible
Object.seal❌❌✅Object.isSealed
Object.freeze❌❌❌Object.isFrozen
JavaScript
"use strict";
const config = Object.freeze({ api: "/v1", retries: 3 });
config.retries = 5;   // TypeError in strict mode (silently ignored otherwise)
config.debug = true;  // TypeError
delete config.api;    // TypeError

const user = Object.seal({ name: "Rohit" });
user.name = "R";      // ✅ allowed
user.age = 30;        // TypeError
delete user.name;     // TypeError

#Deep freeze

Recursively freeze every nested object:

deepFreeze.js
function deepFreeze(obj) {
  Object.freeze(obj); // freeze first, so a circular reference back to obj is skipped below
  for (const key of Reflect.ownKeys(obj)) {
    const value = obj[key];
    if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
      deepFreeze(value);
    }
  }
  return obj;
}

const settings = deepFreeze({ theme: { color: "dark" }, langs: ["en"] });
settings.theme.color = "light"; // ignored / TypeError
settings.langs.push("fr");      // TypeError: object is not extensible

Freezing before recursing, plus the isFrozen check, is what stops infinite recursion on circular references. In TypeScript, as const gives you compile-time read-only types without any runtime cost.

Chapter 07

Async, the Event Loop & Iteration

Promises, async/await, how the event loop orders microtasks and macrotasks, concurrency, generators, workers and animation frames.

#Callbacks → Promises → async/await

Added

JavaScript runs on one thread. Long work (network, timers, disk) is handed to the environment, and you're told when it's done. The way you're told has evolved:

JavaScript
// 1. Callbacks — nesting gets ugly fast ("callback hell")
getUser(id, (err, user) => {
  if (err) return handle(err);
  getOrders(user, (err, orders) => {
    if (err) return handle(err);
    render(orders);
  });
});

// 2. Promises — flat chains, one error handler
getUser(id)
  .then((user) => getOrders(user))
  .then(render)
  .catch(handle);

// 3. async/await — promises that read like synchronous code
try {
  const user = await getUser(id);
  render(await getOrders(user));
} catch (err) {
  handle(err);
}

#Promises

A Promise is an object representing a value that will be available later. It's always in one of three states: pending, fulfilled (with a value) or rejected (with a reason). Once settled, it never changes.

JavaScript
function returnAfter(ms, value) {
  return new Promise((resolve, reject) => {
    if (ms < 0) return reject(new RangeError("ms must be ≥ 0"));
    setTimeout(() => resolve(value), ms);
  });
}

returnAfter(1000, 42)
  .then((v) => v * 2)          // return a value → next then gets it
  .then((v) => returnAfter(500, v + 1)) // return a promise → chain waits for it
  .then(console.log)           // 85
  .catch(console.error)        // catches a rejection from ANY step above
  .finally(() => console.log("done")); // runs either way, receives nothing

#The Promise executor runs synchronously

The function you pass to new Promise(...) runs immediately, right there in the current call stack. Only the .then callbacks are asynchronous.

JavaScript
console.log("1");
const p = new Promise((resolve) => {
  console.log("2 — executor runs now");
  resolve();
  console.log("3 — still runs after resolve()");
});
p.then(() => console.log("5 — then is a microtask"));
console.log("4");

// 1, 2, 3, 4, 5

Also note: calling resolve() doesn't stop the executor — code after it still runs. Use return resolve(x) if you want to exit.

#async/await

async functions let you await a promise and write asynchronous code top-to-bottom.

JavaScript
function returnAfter25secs() {
  return new Promise((resolve) => setTimeout(() => resolve(1), 25_000));
}

async function callThis() {
  const result = await returnAfter25secs(); // pauses THIS function only
  console.log(result); // 1, after 25 seconds
}

callThis();
console.log("this logs first — the rest of the program keeps running");

Errors become exceptions you can try/catch:

JavaScript
async function fetchData() {
  try {
    const res = await fetch("/api/user");
    if (!res.ok) throw new Error(`HTTP ${res.status}`); // fetch doesn't reject on 404/500!
    return await res.json();
  } catch (error) {
    console.error(error);
    return null;
  }
}

#Async functions always return a promise

Whatever you return from an async function is wrapped in a promise; whatever you throw becomes a rejection.

JavaScript
async function one() {
  return 1;
}
one();                 // Promise {<fulfilled>: 1}, not 1
one().then(console.log); // 1

async function fail() {
  throw new Error("nope");
}
fail().catch((e) => console.log(e.message)); // "nope" — no synchronous throw

That's why you can't get a value out of an async function synchronously — callers have to await it or use .then. Top-level await is allowed in ES modules.

#await continuations are microtasks

When an async function hits await, it pauses and returns to its caller. The rest of the function (the continuation) is scheduled as a microtask once the awaited promise settles — even if the value was already available.

JavaScript
async function demo() {
  console.log("B");
  await null;           // even awaiting a non-promise yields
  console.log("D");
}

console.log("A");
demo();
console.log("C");
// A, B, C, D

Everything before the first await runs synchronously as part of the call.

#The event loop

The event loop is how a single-threaded runtime handles asynchronous work:

  1. The engine runs synchronous code on the call stack.
  2. Async operations (setTimeout, fetch, DOM events, file I/O) are handed to the Web APIs (browser) or libuv (Node), which do the work in the background.
  3. When the work finishes, its callback is put in a queue: promise callbacks go in the microtask queue; timers, I/O and UI events go in the macrotask (task) queue.
  4. Whenever the call stack is empty, the event loop drains the entire microtask queue (including microtasks added while draining).
  5. Then it takes one macrotask, runs it, and goes back to step 4. In browsers, rendering can happen between macrotasks.
Call stack console.log() handleClick() main() Web APIs / libuv setTimeout · fetch DOM events · file I/O Event loop ① async work handed off ② callback queued Microtasks drain ALL promise.then await … queueMicrotask Macrotasks ONE at a time setTimeout cb click handler I/O callback ③ when stack is empty
Microtasks always jump ahead of the next macrotask.

#Microtasks vs macrotasks

Microtasks (higher priority)Macrotasks / tasks
promise.then / catch / finallysetTimeout, setInterval
await continuationsDOM events (click, scroll, input)
queueMicrotask(fn)fetch / XHR callbacks, I/O
MutationObserver callbacksMessageChannel, postMessage
Node: process.nextTick (runs even before promises)Node: setImmediate

Microtasks always run before the next macrotask. A setTimeout(fn, 0) is "as soon as possible after the current task and all microtasks" — not immediately.

Predict the output (warm-up)
JavaScript
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
Show answer

1, 4, 3, 2 — synchronous logs first, then the microtask (promise), then the macrotask (timeout).

Predict the output (interview level)
JavaScript
console.log("A");

setTimeout(() => console.log("B"), 0);

new Promise((resolve) => {
  console.log("C");
  resolve();
}).then(() => console.log("D"));

(async () => {
  console.log("E");
  await null;
  console.log("F");
})();

queueMicrotask(() => console.log("G"));

console.log("H");
Show answer

A, C, E, H, D, F, G, B

  • Synchronous: A, then C (executor runs immediately), then E (async function body runs until its first await), then H.
  • Microtasks, in the order they were queued: D (queued when .then was attached to an already-resolved promise), F (the await continuation), G.
  • Macrotask: B.

#Promise combinators for concurrent work

MethodResolves whenRejects whenUse for
Promise.all(ps)All fulfil → array of valuesAny rejects (fail-fast)Independent requests you need together
Promise.allSettled(ps)All settle → array of {status, value | reason}NeverBatch jobs where some may fail
Promise.race(ps)The first to settle (either way)The first to settle rejectsTimeouts
Promise.any(ps)The first to fulfilAll reject → AggregateErrorFastest mirror / fallback sources
JavaScript
// Parallel — total time ≈ the slowest request
const [user, posts] = await Promise.all([
  fetch("/api/user").then((r) => r.json()),
  fetch("/api/posts").then((r) => r.json()),
]);

// Keep going even if some fail
const results = await Promise.allSettled(urls.map((u) => fetch(u)));
const ok = results.filter((r) => r.status === "fulfilled").map((r) => r.value);

// Timeout with race
const timeout = (ms) =>
  new Promise((_, reject) => setTimeout(() => reject(new Error("Timed out")), ms));
const data = await Promise.race([fetch("/slow"), timeout(5000)]);

// First successful mirror
const fastest = await Promise.any([fetch(mirrorA), fetch(mirrorB)]);

#Limiting concurrency

Added

Promise.all(items.map(fetchItem)) fires every request at once — 1,000 items means 1,000 simultaneous requests. A small worker pool caps it:

mapWithLimit.js
async function mapWithLimit(items, limit, fn) {
  const results = new Array(items.length);
  let next = 0;

  async function worker() {
    while (next < items.length) {
      const i = next++;            // safe: JS is single-threaded between awaits
      results[i] = await fn(items[i], i);
    }
  }

  const workers = Array.from({ length: Math.min(limit, items.length) }, worker);
  await Promise.all(workers);
  return results; // same order as items
}

const pages = await mapWithLimit(urls, 5, (url) => fetch(url).then((r) => r.text()));

#Cancelling with AbortController

Added
JavaScript
const controller = new AbortController();

fetch("/api/search?q=js", { signal: controller.signal })
  .then((r) => r.json())
  .catch((err) => {
    if (err.name === "AbortError") return; // we cancelled it — not a real error
    throw err;
  });

controller.abort(); // e.g. the user typed again, or the component unmounted

// Built-in timeout
await fetch("/api/slow", { signal: AbortSignal.timeout(5000) });

Pairs perfectly with debounced search: abort the previous request whenever a new one starts, so stale responses can't overwrite fresh ones.

#Error.cause

ES2022 lets you wrap a low-level error in a more meaningful one without losing the original.

JavaScript
async function loadUser(id) {
  try {
    const res = await fetch(`/api/users/${id}`);
    return await res.json();
  } catch (err) {
    throw new Error(`Failed to load user ${id}`, { cause: err });
  }
}

try {
  await loadUser(7);
} catch (err) {
  console.log(err.message);      // "Failed to load user 7"
  console.log(err.cause);        // the original TypeError / SyntaxError
}

#Iterators and the iteration protocol

Two small contracts power for...of, spread, destructuring, Array.from, Promise.all and more:

  • An iterator is an object with a next() method that returns { value, done }.
  • An iterable is an object with a [Symbol.iterator]() method that returns an iterator.
JavaScript
const arr = ["a", "b"];
const it = arr[Symbol.iterator]();
it.next(); // { value: "a", done: false }
it.next(); // { value: "b", done: false }
it.next(); // { value: undefined, done: true }

Custom iterable

JavaScript
class Range {
  constructor(start, end, step = 1) {
    Object.assign(this, { start, end, step });
  }

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

const r = new Range(1, 10, 3);
[...r];                    // [1, 4, 7, 10]
for (const n of r) console.log(n);
const [first, second] = r; // 1, 4

#Generators

A generator function (function*) can pause at each yield and resume later. Calling it returns a generator object, which is both an iterator and an iterable — so generators are the easiest way to write custom iterables.

JavaScript
function* idGenerator() {
  let id = 1;
  while (true) {
    yield id++; // pause here, hand out a value
  }
}

const ids = idGenerator();
ids.next().value; // 1
ids.next().value; // 2 — infinite, but lazy: values are made only on demand

// The Range class again, much shorter
class Range {
  constructor(start, end) {
    this.start = start;
    this.end = end;
  }
  *[Symbol.iterator]() {
    for (let n = this.start; n <= this.end; n++) yield n;
  }
}
[...new Range(1, 5)]; // [1, 2, 3, 4, 5]

Generators can also receive values: gen.next(x) makes the paused yield expression evaluate to x. That two-way channel is how libraries like redux-saga (and, historically, co before async/await) work.

JavaScript
function* conversation() {
  const name = yield "What's your name?";
  yield `Hello, ${name}!`;
}
const chat = conversation();
chat.next().value;          // "What's your name?"
chat.next("Rohit").value;   // "Hello, Rohit!"

Lazy pipelines with iterator helpers (ES2025, modern browsers and Node 22+):

JavaScript
idGenerator()
  .filter((n) => n % 2 === 0)
  .map((n) => n * 10)
  .take(3)
  .toArray(); // [20, 40, 60] — only 6 values were ever generated

#Async iterators and for await...of

Why for await exists: a regular iterator's next() must return { value, done } synchronously. Data that arrives over time — paginated APIs, streams, sockets — can't do that. An async iterator's next() returns a promise of { value, done }, and for await...of awaits each one in turn.

JavaScript
// Async generator: yield + await in the same function
async function* fetchAllPages(url) {
  while (url) {
    const res = await fetch(url);
    const { items, nextUrl } = await res.json();
    yield items;
    url = nextUrl;
  }
}

for await (const page of fetchAllPages("/api/products?page=1")) {
  render(page); // each page as soon as it arrives; stops when url is null
}

Node.js streams are async iterable too, which makes reading big files line by line simple:

JavaScript
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";

const lines = createInterface({ input: createReadStream("huge.log") });
for await (const line of lines) {
  if (line.includes("ERROR")) console.log(line);
}

#requestAnimationFrame

requestAnimationFrame(callback) asks the browser to run callback right before the next repaint — once per frame, matched to the display's refresh rate (usually 60 Hz, 120 Hz on newer screens).

Where it sits in the event loop: after a task and its microtasks, when the browser decides to render, it runs all queued rAF callbacks → recalculates styles and layout → paints.

JavaScript
const box = document.querySelector(".box");
let start;

function step(timestamp) {           // timestamp: ms since page load, high precision
  start ??= timestamp;
  const elapsed = timestamp - start;
  const x = Math.min(elapsed / 5, 300); // move 300px over 1.5s
  box.style.transform = `translateX(${x}px)`;
  if (x < 300) requestAnimationFrame(step); // schedule the next frame
}

requestAnimationFrame(step);

Why not setTimeout(step, 16)?

  • Timers drift and aren't aligned with the screen's refresh, causing dropped or doubled frames (jank).
  • rAF pauses in background tabs, saving battery and CPU.
  • Multiple DOM changes in one rAF callback are painted together in a single frame.

Also useful for batching DOM reads and writes and for throttling expensive scroll/resize handlers to once per frame. Cancel with cancelAnimationFrame(id).

#Web Workers

A Web Worker runs JavaScript on a separate thread, so heavy computation doesn't freeze the UI. Workers can't touch the DOM; they communicate with the page by passing messages.

main.js
const worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module" });

worker.postMessage({ numbers: Array.from({ length: 1e7 }, (_, i) => i) });

worker.onmessage = (event) => {
  console.log("Sum from worker:", event.data);
};
worker.onerror = (e) => console.error(e.message);
worker.js
self.onmessage = (event) => {
  const { numbers } = event.data;
  let sum = 0;
  for (const n of numbers) sum += n; // the page stays responsive meanwhile
  self.postMessage(sum);
};

Messages are copied with the structured clone algorithm — fine for small data, slow for big data.

#Transferables

Instead of copying, some objects can be transferred: ownership moves to the other thread in near-zero time, and the sender loses access.

JavaScript
const buffer = new ArrayBuffer(100 * 1024 * 1024); // 100 MB
console.log(buffer.byteLength); // 104857600

worker.postMessage(buffer, [buffer]); // second argument: the list to transfer

console.log(buffer.byteLength); // 0 — "detached", it now belongs to the worker

Transferable types include ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas and streams. structuredClone(value, { transfer: [buffer] }) uses the same mechanism.

#Streaming JSON parsing

JSON.parse is synchronous: parsing a 200 MB response blocks the event loop for the whole time — no clicks, no rendering. Options, from simplest:

  1. Move the parse to a Worker so only the worker thread blocks.
  2. Ask the server for NDJSON (one JSON object per line) and parse incrementally as bytes arrive.
  3. In Node, use a streaming parser library such as stream-json.
ndjson.js
async function* readNdjson(url) {
  const res = await fetch(url);
  const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
  let buffer = "";

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += value;
    const lines = buffer.split("\n");
    buffer = lines.pop(); // the last line may be incomplete — keep it for later
    for (const line of lines) {
      if (line.trim()) yield JSON.parse(line); // small parses, the UI stays responsive
    }
  }
  if (buffer.trim()) yield JSON.parse(buffer);
}

for await (const record of readNdjson("/api/export.ndjson")) {
  addRow(record);
}
Chapter 08

Browser, DOM & Events

Script loading, event propagation, delegation (and how React does it), debounce and throttle, passive listeners, storage and CSS specificity.

#Loading scripts: defer, async and type="module"

A plain <script> in the <head> blocks HTML parsing while it downloads and runs — and it can't see elements that haven't been parsed yet.

HTML
<script src="app.js"></script>                <!-- blocks parsing -->
<script src="app.js" defer></script>          <!-- runs after parsing, in order -->
<script src="analytics.js" async></script>    <!-- runs as soon as it arrives, any order -->
<script type="module" src="main.js"></script> <!-- deferred by default + import/export -->
<script> parsing stops while the script downloads and runs <script async> <script defer> <script type="module"> HTML parsing download execute DOMContentLoaded
Only a plain script stops parsing for its download. async pauses parsing just to run; defer and modules wait until parsing is done.
Blocks parsing during downloadRunsOrder keptDOM ready when it runs
<script>YesImmediatelyYesOnly elements above it
asyncNoAs soon as downloadedNoNot guaranteed
deferNoAfter parsing, before DOMContentLoadedYesYes
type="module"NoLike defer (add async to change)YesYes

#Event propagation: capturing and bubbling

When you click an element, the event travels in three phases:

  1. Capturing — from window down through each ancestor to the target.
  2. Target — at the element that was clicked.
  3. Bubbling — back up from the target through each ancestor to window.

addEventListener listens in the bubbling phase by default. Pass { capture: true } (or true) to listen on the way down instead.

HTML
<div id="outer">
  <button id="inner">Click</button>
</div>
JavaScript
const outer = document.getElementById("outer");
const inner = document.getElementById("inner");

outer.addEventListener("click", () => console.log("outer capture"), { capture: true });
outer.addEventListener("click", () => console.log("outer bubble"));
inner.addEventListener("click", () => console.log("inner"));

// Clicking the button logs:
// outer capture → inner → outer bubble

#event.target vs event.currentTarget

  • event.target — the element where the event started (what was actually clicked).
  • event.currentTarget — the element whose listener is running right now (where you attached it). Inside a regular-function handler, this === event.currentTarget.
JavaScript
document.querySelector("ul").addEventListener("click", (e) => {
  console.log(e.target.tagName);        // "LI" (or a <span> inside it!)
  console.log(e.currentTarget.tagName); // "UL" — always the element with the listener
});

#stopPropagation and preventDefault

These do completely different things:

What it stops
e.stopPropagation()The event travelling to other elements (further up/down the tree)
e.stopImmediatePropagation()Other elements and the remaining listeners on this same element
e.preventDefault()The browser's default action — following a link, submitting a form, checking a box, scrolling on touch
JavaScript
// Handle a form with JavaScript instead of reloading the page
form.addEventListener("submit", (e) => {
  e.preventDefault();
  const data = new FormData(form);
  fetch("/api/signup", { method: "POST", body: data });
});

// A click inside a modal shouldn't reach the backdrop's "close" handler
modal.addEventListener("click", (e) => e.stopPropagation());
backdrop.addEventListener("click", closeModal);

#Event delegation

Instead of attaching a listener to every child, attach one listener to a common parent and use event.target to work out which child was clicked. This works because of bubbling.

HTML
<ul id="item-list">
  <li data-id="1">Item 1 <button class="delete">✕</button></li>
  <li data-id="2">Item 2 <button class="delete">✕</button></li>
  <li data-id="3">Item 3 <button class="delete">✕</button></li>
</ul>
JavaScript
document.getElementById("item-list").addEventListener("click", (event) => {
  // closest() handles clicks on nested elements inside the <li>
  const deleteBtn = event.target.closest(".delete");
  if (deleteBtn) {
    deleteBtn.closest("li").remove();
    return;
  }

  const li = event.target.closest("li");
  if (li && event.currentTarget.contains(li)) {
    console.log("List item clicked:", li.dataset.id);
  }
});

Benefits:

  • One listener instead of hundreds — less memory, faster setup.
  • Works for elements added later — new <li>s are handled automatically, no re-binding.
  • Less cleanup — nothing to remove when children are removed.

#Event delegation in React

React uses event delegation for you. Even though you write onClick on individual elements, React attaches one listener per event type to the root container (the element you pass to createRoot; before React 17 it was document).

When an event bubbles up to the root, React works out which component rendered the target from its internal fiber tree, then calls your handlers in the right order with a SyntheticEvent — a cross-browser wrapper around the native event with the same interface (e.target, e.preventDefault(), e.stopPropagation()).

React
function List({ items, onSelect }) {
  // No real listener is attached to each <li> — React delegates to the root
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id} onClick={() => onSelect(item.id)}>
          {item.name}
        </li>
      ))}
    </ul>
  );
}

Why: attaching real listeners to every element in a big component tree would cost a lot of memory and setup time. Delegating to the root uses the browser's own bubbling, and it's why React 17 moved to the root — so multiple React versions (or React inside a non-React page) don't fight over document.

#Debouncing

Debouncing waits until events stop for a set time, then runs the function once. Ideal for search-as-you-type, resize handlers, autosave and form validation.

debounce.js
function debounce(func, delay) {
  let timeoutId;

  return function (...args) {
    clearTimeout(timeoutId); // cancel the previously scheduled call
    timeoutId = setTimeout(() => func.apply(this, args), delay); // keep `this` and args
  };
}

How it works:

  1. func — the function to debounce (e.g. the search).
  2. delay — milliseconds of silence to wait after the last event.
  3. clearTimeout — every new event cancels the pending call, so it can't fire early.
  4. setTimeout — schedules a fresh call; it only runs if no other event arrives within delay.

The timeoutId lives in a closure, so it survives between calls.

JavaScript
function searchQuery(query) {
  console.log("Searching for:", query);
}

const debouncedSearch = debounce(searchQuery, 300);

document.getElementById("search-input").addEventListener("input", (event) => {
  debouncedSearch(event.target.value); // fires once, 300ms after typing stops
});

#Throttling

Throttling runs the function at most once every N ms, no matter how often the event fires. Ideal for scroll position, mousemove, drag and window resize when you need continuous updates but not hundreds per second.

throttle.js
function throttle(func, interval) {
  let lastCall = 0;
  let timeoutId;

  return function (...args) {
    const now = Date.now();
    const remaining = interval - (now - lastCall);

    if (remaining <= 0) {
      clearTimeout(timeoutId);
      timeoutId = undefined;
      lastCall = now;
      func.apply(this, args);                // leading call
    } else if (!timeoutId) {
      timeoutId = setTimeout(() => {         // trailing call, so the last event isn't lost
        lastCall = Date.now();
        timeoutId = undefined;
        func.apply(this, args);
      }, remaining);
    }
  };
}

window.addEventListener("scroll", throttle(() => {
  console.log("scroll position:", window.scrollY);
}, 200));
DebounceThrottle
FiresOnce, after the burst endsRegularly, during the burst
An event every 50ms for 3 seconds, 200ms setting1 call, 200ms after the last event~15 calls, spread evenly
Typical useSearch box, autosave, resize endScroll, mousemove, infinite scroll, game input

#Passive event listeners

Touch and wheel listeners can call preventDefault() to stop scrolling, so the browser normally has to wait for your handler to finish before it scrolls — any slow handler makes scrolling janky.

Marking a listener passive: true promises you won't call preventDefault(), so the browser can scroll immediately on the compositor thread.

JavaScript
window.addEventListener(
  "touchmove",
  (e) => {
    updateParallax(window.scrollY);
    // e.preventDefault() here would be ignored (and log a warning)
  },
  { passive: true },
);

Modern browsers already treat touchstart, touchmove and wheel listeners on window, document and body as passive by default. If you genuinely need to block scrolling (a custom carousel), set { passive: false } explicitly.

Other useful listener options: { once: true } removes the listener after the first call; { signal } removes it when an AbortController aborts.

#localStorage, sessionStorage and cookies

Added
localStoragesessionStorageCookies
LifetimeUntil clearedUntil the tab closesUntil the expiry date (or session)
Shared across tabsYes (same origin)No — per tabYes
Size~5–10 MB~5 MB~4 KB each
Sent to the serverNoNoYes, with every request
Readable from JSYesYesYes, unless HttpOnly
JavaScript
localStorage.setItem("theme", "dark");          // values are always strings
localStorage.getItem("theme");                  // "dark"
localStorage.setItem("user", JSON.stringify({ id: 1 }));
JSON.parse(localStorage.getItem("user"));

// React to changes made in OTHER tabs
addEventListener("storage", (e) => console.log(e.key, e.newValue));

#CSS specificity

When several rules target the same element, the browser picks the one with the highest specificity. Think of it as a score with columns compared left to right:

ColumnWhat countsExample
Inline stylesstyle="…" attribute<h1 style="color:red">
A — IDs#id#header
B — Classes, attributes, pseudo-classes.class, [type="text"], :hover, :nth-child().title:hover
C — Elements and pseudo-elementsdiv, p, ::beforeh1::after
No weight*, combinators > + ~ (space)
HTML
<h1 class="title" id="header">Hello</h1>
CSS
#header { color: red; }   /* (1, 0, 0) — wins */
.title  { color: blue; }  /* (0, 1, 0) */
h1      { color: green; } /* (0, 0, 1) */

ul li.active a { }        /* (0, 1, 3) */
.nav .item     { }        /* (0, 2, 0) — beats the one above: B column is compared first */

#header wins because a single ID outranks any number of classes. If two selectors tie, the one that appears later wins.

!important

!important overrides normal specificity — it beats every normal declaration, including inline styles.

CSS
p { color: blue !important; }
#special { color: red; }  /* loses, even though the ID is more specific */

Two !important rules fight with normal specificity again. Treat it as a last resort (e.g. utility classes or overriding third-party CSS); overusing it makes styles impossible to reason about. Cascade layers (@layer) are the modern way to control priority.

Inheritance is different from specificity

Some properties are inherited by children automatically — mostly text-related ones like color, font-*, line-height, visibility. Box-model properties like margin, padding, border and background are not.

Inherited values have no specificity at all: any rule that targets the child directly, even *, beats a value inherited from the parent.

CSS
#article { color: red; }   /* very specific… */
p { color: black; }        /* …but this targets the <p> directly, so the <p> is black */

Force inheritance with color: inherit, or reset with initial, unset and revert.

Chapter 09

Modules

ES modules vs CommonJS, named and default exports, live bindings, dynamic import, and why modules behave like singletons.

#ES modules: named and default exports

ES modules (ESM) are JavaScript's built-in module system. In the browser you opt in with <script type="module">; in Node with .mjs files or "type": "module" in package.json.

There are two kinds of export:

Named exports — many per file, imported with { } using the exact name

JavaScript
// math.js
export const PI = 3.14159;
export function add(a, b) {
  return a + b;
}
const secret = 42; // not exported → private to the file
JavaScript
// main.js
import { PI, add } from "./math.js";
import { add as sum } from "./math.js"; // rename

Default export — at most one per file, imported without { } under any name

JavaScript
// api.js
const api = "1234";
export default api;

// Other valid forms (still one per file):
//   export default "1234";
//   export default function getApi() {}
// ❌ export default let api = "1234";
JavaScript
// main.js
import api from "./api.js";
import whatever from "./api.js"; // same value — you pick the name
JavaScript
// Both in one import
import React, { useState, useEffect } from "react";

// Namespace import: group every named export into one object
import * as utils from "./utils.js";
utils.add(1, 2);
utils.default; // the default export, if there is one

#Re-exports and dynamic import()

Added
JavaScript
// components/index.js — a "barrel" file
export { Button } from "./Button.js";
export { default as Modal } from "./Modal.js";
export * from "./icons.js";

import() loads a module on demand and returns a promise. Bundlers split it into a separate chunk — this is how lazy loading and code splitting work.

JavaScript
button.addEventListener("click", async () => {
  const { openEditor } = await import("./editor.js"); // downloaded only when needed
  openEditor();
});

// React
const Settings = React.lazy(() => import("./Settings.jsx"));

// Works in CommonJS too — the way to load an ESM-only package from CJS
const { default: chalk } = await import("chalk");

Static import declarations must be at the top level and use a literal string; import() can be used anywhere, with a computed path.

#CommonJS

CommonJS (CJS) is Node's original module system: require to import, module.exports (or exports) to export.

JavaScript
// greet.js — exporting a single value
module.exports = function () {
  console.log("Hello from CommonJS");
};

// math.js — exporting several things
exports.add = (a, b) => a + b;
exports.PI = 3.14159;
// same as: module.exports = { add, PI };

// main.js
const greet = require("./greet");
const { add, PI } = require("./math"); // extension optional
greet();

#Live bindings: the really important gotcha

ES module imports are live, read-only views of the exported variable. CommonJS require gives you a copy of whatever module.exports held at that moment.

ES modules — live

JavaScript
// counter.mjs
export let count = 0;
export function increment() {
  count++;
}
JavaScript
// main.mjs
import { count, increment } from "./counter.mjs";
console.log(count); // 0
increment();
console.log(count); // 1 ✅ sees the update

count = 5; // TypeError: Assignment to constant variable

CommonJS — copied value

JavaScript
// counter.js
let count = 0;
module.exports = {
  count,
  increment() {
    count++;
  },
};
JavaScript
// main.js
const { count, increment } = require("./counter");
console.log(count); // 0
increment();
console.log(count); // 0 ❌ still the old copy

With CommonJS, to see current state you'd export a getter (get count() { return count; }) or a function (getCount()).

#CommonJS vs ES modules

CommonJSES modules
Syntaxrequire() / module.exportsimport / export
LoadingSynchronous, at runtimeAsynchronous, parsed before running
AnalysisDynamic — require can be anywhere, with any stringStatic — imports known up front
Tree shakingHardYes — bundlers drop unused exports
BindingsCopied valuesLive, read-only
Top-level awaitNoYes
this at top levelmodule.exportsundefined
__dirname, __filenameAvailableUse import.meta.dirname / import.meta.filename (Node 20.11+)
Strict modeOpt-inAlways
Runs inNode (and bundlers)Browsers, Node, Deno, Bun

#AMD vs CommonJS (history)

Before ES2015 there was no module system in the language, so the community made two:

  • CommonJS — synchronous require, designed for servers where files are on local disk. Became Node's system.
  • AMD (Asynchronous Module Definition, e.g. RequireJS) — asynchronous loading designed for browsers, where every module is a network request.
JavaScript
// AMD
define(["jquery", "./utils"], function ($, utils) {
  return { init() { /* ... */ } };
});

AMD's syntax is verbose, and once apps were bundled into one file, async per-module loading added little. UMD wrapped code so it worked in both.

#Modules are singletons

A module's top-level code runs once, the first time it's imported. After that, every importer gets the same cached instance. (Same for CommonJS, via require.cache.) So module-level state is naturally shared — a module is effectively a singleton.

JavaScript
// store.js
const globalMap = new Map(); // created once, no matter how many files import this

export default {
  getInstance() {
    return globalMap;
  },
};
a.js
import store from "./store.js";
store.getInstance().set("user", "Rohit");
b.js
import store from "./store.js";
store.getInstance().get("user"); // "Rohit" — same Map

Exporting an object with methods (instead of exporting getInstance directly) gives the module a tidy, namespaced API: store.getInstance().

Chapter 10

Design Patterns

Singleton, factory, observer, decorator (the pattern and the @ syntax), dependency injection and object pooling — in idiomatic JavaScript.

#Singleton

A singleton guarantees there's only one instance of something — a DB connection, a config store, a logger — with one global access point.

JavaScript
class Database {
  static #instance = null;

  constructor() {
    if (Database.#instance) {
      return Database.#instance; // `new` hands back the existing instance
    }
    this.connectedAt = Date.now();
    Database.#instance = this;
  }

  static getInstance() {
    return (Database.#instance ??= new Database());
  }

  query(sql) {
    return `running: ${sql}`;
  }
}

const a = Database.getInstance();
const b = new Database();
a === b; // true

ES modules are naturally singleton-ish

Since a module's code runs only once and is cached (see Modules are singletons), the simplest singleton in modern JS is just exporting an instance:

JavaScript
// logger.js
class Logger {
  #logs = [];
  log(msg) {
    this.#logs.push(msg);
    console.log(`[${new Date().toISOString()}] ${msg}`);
  }
  get count() {
    return this.#logs.length;
  }
}

export const logger = new Logger(); // every importer shares this one

#Factory pattern

A factory is a function that creates objects for you, so callers don't need to know which class to instantiate or how to configure it.

JavaScript
class EmailNotifier {
  constructor({ address }) {
    this.address = address;
  }
  send(msg) {
    return `Email to ${this.address}: ${msg}`;
  }
}
class SmsNotifier {
  constructor({ phone }) {
    this.phone = phone;
  }
  send(msg) {
    return `SMS to ${this.phone}: ${msg}`;
  }
}

const notifiers = {
  email: EmailNotifier,
  sms: SmsNotifier,
};

function createNotifier(type, options) {
  const Notifier = notifiers[type];
  if (!Notifier) throw new Error(`Unknown notifier type: ${type}`);
  return new Notifier(options);
}

createNotifier("sms", { phone: "+1 555 0100" }).send("Your code is 1234");

Adding a new type means registering it in the map — the calling code never changes (open/closed principle). Factory functions that return object literals (const createUser = (name) => ({ name, … })) are also very common in JS and avoid new and this entirely.

#Observer / pub-sub (EventEmitter)

Added

The observer pattern lets objects subscribe to events from another object without either knowing the details of the other. It's everywhere: DOM events, Node's EventEmitter, Redux store.subscribe, RxJS.

EventEmitter.js
class EventEmitter {
  #listeners = new Map(); // event name → Set of functions

  on(event, listener) {
    if (!this.#listeners.has(event)) this.#listeners.set(event, new Set());
    this.#listeners.get(event).add(listener);
    return () => this.off(event, listener); // return an unsubscribe function
  }

  off(event, listener) {
    this.#listeners.get(event)?.delete(listener);
  }

  once(event, listener) {
    const wrapper = (...args) => {
      this.off(event, wrapper);
      listener(...args);
    };
    return this.on(event, wrapper);
  }

  emit(event, ...args) {
    // copy first, so listeners that unsubscribe during emit don't break the loop
    for (const listener of [...(this.#listeners.get(event) ?? [])]) {
      listener(...args);
    }
  }
}

const cart = new EventEmitter();
const unsubscribe = cart.on("add", (item) => console.log("Added", item));
cart.once("add", () => console.log("First item!"));

cart.emit("add", "Book"); // Added Book, First item!
cart.emit("add", "Pen");  // Added Pen
unsubscribe();
cart.emit("add", "Cup");  // (nothing)

#Decorator pattern (higher-order wrappers)

The decorator pattern adds behaviour to a function or object by wrapping it, without modifying the original. In JavaScript that's basically a higher-order function.

JavaScript
function withLogging(fn) {
  return function (...args) {
    console.log(`→ ${fn.name}(${args.join(", ")})`);
    const result = fn.apply(this, args);
    console.log(`← ${result}`);
    return result;
  };
}

function withTiming(fn) {
  return function (...args) {
    const start = performance.now();
    try {
      return fn.apply(this, args);
    } finally {
      console.log(`${fn.name} took ${(performance.now() - start).toFixed(1)}ms`);
    }
  };
}

function add(a, b) {
  return a + b;
}

const tracedAdd = withLogging(withTiming(add)); // decorators stack
tracedAdd(2, 3);

debounce, throttle, memoize, once and React's memo() / higher-order components are all decorators in this sense.

#Decorators syntax (@decorator)

The @decorator syntax applies a wrapper to a class or class member declaratively. It's a TC39 Stage 3 proposal — supported today by TypeScript 5+ and Babel, and arriving natively in engines. A method decorator receives the original method and a context object, and returns a replacement.

JavaScript
function logged(originalMethod, context) {
  const name = String(context.name);
  return function (...args) {
    console.log(`→ ${name}(${args.join(", ")})`);
    const result = originalMethod.call(this, ...args);
    console.log(`← ${name} returned`, result);
    return result;
  };
}

// A decorator factory: call it with options, it returns the decorator
function retry(times) {
  return function (originalMethod, context) {
    return async function (...args) {
      let lastError;
      for (let attempt = 1; attempt <= times; attempt++) {
        try {
          return await originalMethod.call(this, ...args);
        } catch (err) {
          lastError = err;
          console.warn(`${String(context.name)} failed (attempt ${attempt}/${times})`);
        }
      }
      throw lastError;
    };
  };
}

class ApiClient {
  @retry(3)
  async fetchData(url) {
    const res = await fetch(url);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json();
  }

  @logged
  add(a, b) {
    return a + b;
  }
}

await new ApiClient().fetchData("/api/flaky"); // retried up to 3 times

#Dependency injection

Dependency injection (DI) means a class receives the things it depends on instead of creating them itself. That makes it easy to swap implementations — most importantly, to use fakes in tests.

Without DI — hard-wired

JavaScript
class UserService {
  constructor() {
    this.repo = new PostgresUserRepo(); // fixed
    this.mailer = new SendGridMailer(); // fixed
  }
}
// Testing this hits a real DB and sends real email 😬

With DI — passed in

JavaScript
class UserService {
  constructor({ repo, mailer }) {
    this.repo = repo;
    this.mailer = mailer;
  }
  async register(data) {
    const user = await this.repo.create(data);
    await this.mailer.send(user.email, "Welcome!");
    return user;
  }
}
JavaScript
// Production wiring (the "composition root")
const service = new UserService({
  repo: new PostgresUserRepo(db),
  mailer: new SendGridMailer(apiKey),
});

// Test wiring — no database, no network
const sent = [];
const testService = new UserService({
  repo: { create: async (d) => ({ id: 1, ...d }) },
  mailer: { send: async (to, msg) => sent.push({ to, msg }) },
});
await testService.register({ email: "a@b.com" });
console.log(sent); // [{ to: "a@b.com", msg: "Welcome!" }]

Frameworks such as Angular and NestJS add a DI container that builds and injects dependencies automatically. React's Context is a form of DI for components.

#Object pooling

Added

From the very first line of my notes: "Look into object pooling."

Object pooling reuses a set of pre-created objects instead of constantly creating and discarding them. Creating thousands of short-lived objects per second (particles, bullets, vectors) makes the garbage collector run often, and GC pauses show up as dropped frames.

ObjectPool.js
class ObjectPool {
  #free = [];

  constructor(create, reset, initialSize = 0) {
    this.create = create;
    this.reset = reset;
    for (let i = 0; i < initialSize; i++) this.#free.push(create());
  }

  acquire() {
    return this.#free.pop() ?? this.create(); // reuse if possible, otherwise make one
  }

  release(obj) {
    this.reset(obj);       // clean it so no old state leaks into the next user
    this.#free.push(obj);
  }

  get available() {
    return this.#free.length;
  }
}

const particles = new ObjectPool(
  () => ({ x: 0, y: 0, vx: 0, vy: 0, life: 0 }),
  (p) => Object.assign(p, { x: 0, y: 0, vx: 0, vy: 0, life: 0 }),
  500,
);

function spawn(x, y) {
  const p = particles.acquire();
  Object.assign(p, { x, y, vx: Math.random() - 0.5, vy: -1, life: 60 });
  return p;
}
// when p.life hits 0: particles.release(p)

The same idea for expensive resources is everywhere in back-end code: database connection pools (pg.Pool), HTTP keep-alive agents and worker-thread pools.

Chapter 11

Coding Challenges

"Implement it yourself" questions with clean solutions: polyfills, caches, data structures and string utilities. Debounce and throttle are in Browser, DOM & Events; curry and compose are in Functions.

#Polyfill: Array.prototype.map

My "head teaser" from the notes. The details interviewers look for: respect thisArg, pass (value, index, array), and skip holes in sparse arrays.

myMap.js
Array.prototype.myMap = function (callbackFn, thisArg) {
  if (typeof callbackFn !== "function") {
    throw new TypeError(`${callbackFn} is not a function`);
  }

  const len = this.length;
  const result = new Array(len);

  for (let k = 0; k < len; k++) {
    // Skip holes in sparse arrays, like the real map does
    if (Object.hasOwn(this, k)) {
      result[k] = callbackFn.call(thisArg, this[k], k, this);
    }
  }
  return result;
};

[1, 2, , 4].myMap((i) => i * i); // [1, 4, <empty>, 16]

#Polyfill: filter and reduce

Added
JavaScript
Array.prototype.myFilter = function (callbackFn, thisArg) {
  const result = [];
  for (let i = 0; i < this.length; i++) {
    if (Object.hasOwn(this, i) && callbackFn.call(thisArg, this[i], i, this)) {
      result.push(this[i]);
    }
  }
  return result;
};

Array.prototype.myReduce = function (callbackFn, ...initial) {
  let i = 0;
  let acc;

  if (initial.length) {
    acc = initial[0];
  } else {
    while (i < this.length && !Object.hasOwn(this, i)) i++; // first real element
    if (i >= this.length) throw new TypeError("Reduce of empty array with no initial value");
    acc = this[i++];
  }

  for (; i < this.length; i++) {
    if (Object.hasOwn(this, i)) acc = callbackFn(acc, this[i], i, this);
  }
  return acc;
};

[1, 2, 3, 4].myFilter((n) => n % 2 === 0);  // [2, 4]
[1, 2, 3, 4].myReduce((a, b) => a + b);     // 10
[].myReduce((a, b) => a + b, 0);            // 0

...initial (instead of initial = undefined) lets us tell "no initial value" apart from an explicit undefined.

#compose with a loop

The loop version from my notes, tidied up. (The one-liner with reduceRight is in Function composition.)

compose.js
export default function compose(...fns) {
  return function (value) {
    let result = value;
    for (let i = fns.length - 1; i >= 0; i--) {
      result = fns[i](result);
    }
    return result;
  };
}

const add1 = (num) => num + 1;
const double = (num) => num * 2;
const subtract10 = (num) => num - 10;

const composedFn = compose(subtract10, double, add1);
composedFn(3); // (3 + 1) * 2 - 10 = -2

#Polyfill: Promise.all

Added
promiseAll.js
function promiseAll(iterable) {
  return new Promise((resolve, reject) => {
    const items = Array.from(iterable);
    const results = new Array(items.length);
    let remaining = items.length;

    if (remaining === 0) return resolve([]);

    items.forEach((item, index) => {
      // Promise.resolve handles plain values and thenables too
      Promise.resolve(item).then((value) => {
        results[index] = value;          // keep the original order
        if (--remaining === 0) resolve(results);
      }, reject);                        // first rejection wins
    });
  });
}

await promiseAll([1, Promise.resolve(2), new Promise((r) => setTimeout(() => r(3), 100))]);
// [1, 2, 3]

Key points: results stay in input order (not finish order), non-promise values are allowed, an empty input resolves to [] immediately, and the first rejection rejects the whole thing.

#Memoize

Added

Cache a pure function's results by its arguments.

memoize.js
function memoize(fn, keyFn = (...args) => JSON.stringify(args)) {
  const cache = new Map();
  return function (...args) {
    const key = keyFn(...args);
    if (cache.has(key)) return cache.get(key);
    const result = fn.apply(this, args);
    cache.set(key, result);
    return result;
  };
}

const slowSquare = (n) => {
  for (let i = 0; i < 1e8; i++); // pretend this is expensive
  return n * n;
};
const fastSquare = memoize(slowSquare);
fastSquare(9); // slow the first time
fastSquare(9); // instant

// Recursive memoization: make the recursive calls go through the memoized version
const fib = memoize((n) => (n < 2 ? n : fib(n - 1) + fib(n - 2)));
fib(50); // 12586269025, instantly (the naive version makes ~40 billion calls)

#In-memory cache with a TTL

From my notes: a cache where entries expire after ttl milliseconds. Version 1 uses a timer per key to delete expired entries eagerly:

InMemoryCache.js
class InMemoryCache {
  constructor(ttl = 20_000) {
    this.ttl = ttl;
    this.cache = {};
  }

  set(key, value) {
    if (this.cache[key]) {
      clearTimeout(this.cache[key].timeoutId); // replacing a key resets its timer
    }

    const timeoutId = setTimeout(() => {
      delete this.cache[key];
    }, this.ttl);

    this.cache[key] = {
      value,
      expiresAt: Date.now() + this.ttl,
      timeoutId,
    };
  }

  get(key) {
    const entry = this.cache[key];
    if (!entry) return null;
    if (entry.expiresAt > Date.now()) return entry.value;
    delete this.cache[key];
    return null;
  }
}

module.exports = InMemoryCache;

#Lazy-expiry cache (better approach and trade-offs)

Version 2 drops the timers and checks expiry lazily, when an entry is read:

Cache.js
class Cache {
  constructor(ttl = 20_000) {
    this.ttl = ttl;
    this.cache = new Map();
  }

  set(key, value) {
    this.cache.set(key, { value, expiresAt: Date.now() + this.ttl });
  }

  get(key) {
    const entry = this.cache.get(key);
    if (!entry) return undefined;

    if (Date.now() >= entry.expiresAt) {
      this.cache.delete(key);
      return undefined;
    }
    return entry.value;
  }
}
Timer per key (v1)Lazy expiry (v2)
Memory freedImmediately at expiryOnly when the key is read again
CostOne timer per key — thousands of timers is expensiveNothing extra
Keeps a Node process aliveYes, unless you call timer.unref()No
StorageObject — keys become strings, prototype keys like "constructor" can clashMap — any key type, no clashes
TestabilityNeeds fake timersJust mock Date.now()

Best of both: lazy expiry plus an occasional sweep (setInterval(() => …, 60_000).unref()) that deletes expired entries in bulk, and a max size so the cache can't grow without limit — which leads to the LRU cache.

#LRU cache

Added

A Least Recently Used cache holds at most capacity items and evicts the one unused for the longest. A favourite interview question — and Map's insertion order makes it short: re-inserting a key moves it to the end, so the first key is always the least recently used.

LRUCache.js
class LRUCache {
  #map = new Map();

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

  get(key) {
    if (!this.#map.has(key)) return -1;
    const value = this.#map.get(key);
    this.#map.delete(key);   // move to the "most recent" end
    this.#map.set(key, value);
    return value;
  }

  put(key, value) {
    this.#map.delete(key);
    this.#map.set(key, value);
    if (this.#map.size > this.capacity) {
      const oldestKey = this.#map.keys().next().value;
      this.#map.delete(oldestKey);
    }
  }
}

const lru = new LRUCache(2);
lru.put("a", 1);
lru.put("b", 2);
lru.get("a");    // 1 — "a" is now most recent
lru.put("c", 3); // evicts "b"
lru.get("b");    // -1

All operations are O(1). In languages without an ordered hash map, you'd combine a hash map with a doubly linked list.

#Implementing a stack

A stack is last-in, first-out (LIFO): push adds to the top, pop removes from the top, peek looks at the top without removing it.

Stack.js
class Stack {
  #items = [];

  push(item) {
    this.#items.push(item);
    return this;
  }

  pop() {
    if (this.isEmpty()) throw new Error("Stack underflow");
    return this.#items.pop();
  }

  peek() {
    return this.#items.at(-1); // undefined if empty
  }

  isEmpty() {
    return this.#items.length === 0;
  }

  get size() {
    return this.#items.length;
  }
}

const s = new Stack().push(1).push(2).push(3);
s.peek();    // 3
s.pop();     // 3
s.size;      // 2
s.isEmpty(); // false

Classic stack problem — balanced brackets:

JavaScript
function isBalanced(str) {
  const pairs = { ")": "(", "]": "[", "}": "{" };
  const stack = [];
  for (const ch of str) {
    if ("([{".includes(ch)) stack.push(ch);
    else if (ch in pairs && stack.pop() !== pairs[ch]) return false;
  }
  return stack.length === 0;
}

isBalanced("{[()]}"); // true
isBalanced("([)]");   // false

#Capitalize the first letter of each word

Input "hi how Are you" → output "Hi How Are You".

JavaScript
// 1. split / map / join
const capitalize = (str) =>
  str
    .split(" ")
    .map((word) => (word ? word[0].toUpperCase() + word.slice(1) : word))
    .join(" ");

// 2. Regex: \b\w matches the first letter of each word
const capitalize2 = (str) => str.replace(/\b\w/g, (ch) => ch.toUpperCase());

// 3. Title case: also lowercase the rest
const titleCase = (str) =>
  str.toLowerCase().replace(/(^|\s)\S/g, (m) => m.toUpperCase());

capitalize("hi how Are you"); // "Hi How Are You"
titleCase("hI hOW aRE yOU");  // "Hi How Are You"

#Custom JSON.stringify

A simplified version that covers the main rules: strings are quoted and escaped, undefined/functions/symbols are skipped in objects and become null in arrays, NaN/Infinity become null, and toJSON is respected (that's how Date works).

stringify.js
function stringify(value) {
  if (value !== null && typeof value?.toJSON === "function") {
    value = value.toJSON();
  }

  if (value === null) return "null";

  switch (typeof value) {
    case "string":
      return `"${value
        .replace(/\\/g, "\\\\")
        .replace(/"/g, '\\"')
        .replace(/\n/g, "\\n")
        .replace(/\r/g, "\\r")
        .replace(/\t/g, "\\t")}"`;
    case "number":
      return Number.isFinite(value) ? String(value) : "null";
    case "boolean":
      return String(value);
    case "bigint":
      throw new TypeError("Do not know how to serialize a BigInt");
    case "undefined":
    case "function":
    case "symbol":
      return undefined; // caller decides: skip (object) or "null" (array)
  }

  if (Array.isArray(value)) {
    return `[${value.map((item) => stringify(item) ?? "null").join(",")}]`;
  }

  const props = Object.keys(value)
    .map((key) => {
      const str = stringify(value[key]);
      return str === undefined ? undefined : `${stringify(key)}:${str}`;
    })
    .filter((p) => p !== undefined);
  return `{${props.join(",")}}`;
}

stringify({ a: 1, b: [true, undefined, "x"], c: undefined, d: new Date(0) });
// '{"a":1,"b":[true,null,"x"],"d":"1970-01-01T00:00:00.000Z"}'

The real one also handles circular references (it throws), the replacer and space arguments, and boxed primitives like new String("x").

#Flatten an array (without flat)

Added
JavaScript
// Recursive, with an optional depth like the real flat()
function flatten(arr, depth = Infinity) {
  return arr.reduce(
    (acc, item) =>
      Array.isArray(item) && depth > 0
        ? acc.concat(flatten(item, depth - 1))
        : acc.concat([item]),
    [],
  );
}

// Iterative with a stack — no recursion limit
function flattenIterative(arr) {
  const stack = [...arr];
  const result = [];
  while (stack.length) {
    const next = stack.pop();
    if (Array.isArray(next)) stack.push(...next);
    else result.push(next);
  }
  return result.reverse();
}

flatten([1, [2, [3, [4]]]]);    // [1, 2, 3, 4]
flatten([1, [2, [3, [4]]]], 1); // [1, 2, [3, [4]]]
flattenIterative([1, [2, [3]]]); // [1, 2, 3]

acc.concat([item]) (not acc.concat(item)) matters when depth runs out: concat would otherwise flatten one more level of a nested array.

#Deep equality

Added
deepEqual.js
function deepEqual(a, b) {
  if (Object.is(a, b)) return true; // same reference, or equal primitives (NaN-safe)

  if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) {
    return false;
  }
  if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;

  if (a instanceof Date) return a.getTime() === b.getTime();

  const keysA = Reflect.ownKeys(a);
  const keysB = Reflect.ownKeys(b);
  if (keysA.length !== keysB.length) return false;

  return keysA.every((key) => Object.hasOwn(b, key) && deepEqual(a[key], b[key]));
}

deepEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] }); // true
deepEqual([1, 2], { 0: 1, 1: 2 });                     // false — different prototypes
deepEqual(NaN, NaN);                                   // true

For Maps, Sets and circular structures, extend it (or use node:assert's deepStrictEqual / lodash isEqual).