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.
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:
Primitive
Example
typeof
string
"hi"
"string"
number
42, 3.14, NaN
"number"
bigint
10n
"bigint"
boolean
true
"boolean"
undefined
undefined
"undefined"
null
null
"object" ⚠
symbol
Symbol("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
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: 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 everyx 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.
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
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
Value
typeof
Accessing it
Undeclared
—
"undefined"
ReferenceError
Declared, not assigned
undefined
"undefined"
undefined
null
intentional "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.
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
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();
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 newcount, 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 samei, 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);
}
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.
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
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.
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
Property lookup walks right until it finds the key or reaches null.
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.
The 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]] link
Every 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
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.
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
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.
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.
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.
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.
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.
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 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
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
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.
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!"
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" }).
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.
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)) {}
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.
// 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)).
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.
[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).
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!
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.
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.
// 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.assignsets properties on the target (so it triggers setters), while spread defines new ones.
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.
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:
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.
Deep copy of plain data, Dates, Maps, Sets, cycles
structuredClone
Deep copy that must keep class prototypes / functions
lodash.cloneDeep or a custom function
Quick deep copy of pure JSON data
JSON.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
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.
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:
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
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;
}
}
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.
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 is how a single-threaded runtime handles asynchronous work:
The engine runs synchronous code on the call stack.
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.
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.
Whenever the call stack is empty, the event loop drains the entire microtask queue (including microtasks added while draining).
Then it takes one macrotask, runs it, and goes back to step 4. In browsers, rendering can happen between macrotasks.
Microtasks always jump ahead of the next macrotask.
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.
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.
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
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(callback) asks the browser to run callbackright 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).
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.
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.
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.
JSON.parse is synchronous: parsing a 200 MB response blocks the event loop for the whole time — no clicks, no rendering. Options, from simplest:
Move the parse to a Worker so only the worker thread blocks.
Ask the server for NDJSON (one JSON object per line) and parse incrementally as bytes arrive.
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.
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 -->
Only a plain script stops parsing for its download. async pauses parsing just to run; defer and modules wait until parsing is done.
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
});
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);
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.
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 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:
func — the function to debounce (e.g. the search).
delay — milliseconds of silence to wait after the last event.
clearTimeout — every new event cancels the pending call, so it can't fire early.
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.
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));
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.
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:
Column
What counts
Example
Inline styles
style="…" 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-elements
div, p, ::before
h1::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 (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
// 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 (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();
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
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.
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.
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)
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.
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 (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 😬
// 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.
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.
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]
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.
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.
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)
One timer per key — thousands of timers is expensive
Nothing extra
Keeps a Node process alive
Yes, unless you call timer.unref()
No
Storage
Object — keys become strings, prototype keys like "constructor" can clash
Map — any key type, no clashes
Testability
Needs fake timers
Just 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.
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.
// 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"
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").