A deep dive into the concepts that separate junior devs from engineers who actually understand the runtime.
Part 1: Scope and Closures
var vs let vs const — It's Not Just Syntax
Most devs learn early that let and const are "the modern way" and var is old. But the real difference runs deeper than style.
Hoisting
JavaScript hoists declarations to the top of their scope before execution. But how that hoisting behaves depends on which keyword you use.
console.log(name); // undefined (not an error!)
var name = "Toqeer";
console.log(age); // ReferenceError: Cannot access 'age' before initialization
let age = 27;
var is hoisted and initialized to undefined. So the engine sees this:
var name; // hoisted to top
console.log(name); // undefined
name = "Toqeer";
let and const are also hoisted — but they are not initialized. They exist in what's called the Temporal Dead Zone (TDZ) from the start of the block until the declaration is reached.
Temporal Dead Zone (TDZ)
{
// TDZ for `score` starts here
console.log(score); // ReferenceError
let score = 100; // TDZ ends here
}
The variable exists in the scope, the engine knows about it — but you cannot touch it yet. This is intentional. It prevents you from accidentally using a variable before it's ready.
const adds one more layer: once assigned, the binding cannot be reassigned. For objects and arrays, the reference is locked — but the contents can still mutate.
const config = { debug: false };
config.debug = true; // fine
config = {}; // TypeError: Assignment to constant variable
Scope: Function vs Block
var is function-scoped. It leaks out of blocks like if, for, while.
function checkStatus() {
if (true) {
var status = "active";
}
console.log(status); // "active" — leaked out of the if block
}
let and const are block-scoped. They stay inside {}.
function checkStatus() {
if (true) {
let status = "active";
}
console.log(status); // ReferenceError
}
This is why the classic loop bug exists:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Prints: 3, 3, 3
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Prints: 0, 1, 2
With var, there's only one i shared across all iterations. With let, each iteration gets its own block-scoped i.
Closures: One of JavaScript's Superpowers
A closure is a function that remembers the variables from its outer scope — even after that outer scope has finished executing.
function makeCounter() {
let count = 0;
return function () {
count++;
return count;
};
}
const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
makeCounter has long since returned. Its execution context is gone. But count lives on — because the inner function closed over it.
How Closures Form
A closure forms every time a function is created inside another function and references variables from the outer scope. The inner function holds a reference to the outer environment, not a copy.
Practical Uses
1. Data privacy / encapsulation:
function createBankAccount(initialBalance) {
let balance = initialBalance;
return {
deposit(amount) { balance += amount; },
withdraw(amount) { balance -= amount; },
getBalance() { return balance; }
};
}
const account = createBankAccount(1000);
account.deposit(500);
console.log(account.getBalance()); // 1500
// `balance` is not accessible from outside
2. Function factories:
function multiplier(factor) {
return (number) => number * factor;
}
const double = multiplier(2);
const triple = multiplier(3);
console.log(double(5)); // 10
console.log(triple(5)); // 15
3. Memoization:
function memoize(fn) {
const cache = {};
return function (n) {
if (cache[n] !== undefined) return cache[n];
cache[n] = fn(n);
return cache[n];
};
}
const factorial = memoize(function f(n) {
return n <= 1 ? 1 : n * f(n - 1);
});
IIFE — Immediately Invoked Function Expression
An IIFE is a function that runs the moment it's defined.
(function () {
const secret = "hidden";
console.log("Runs immediately");
})();
// `secret` is not accessible out here
The outer () wraps the function expression (makes it an expression, not a declaration), and the trailing () invokes it immediately.
Why use it?
Before ES modules and block scoping with let/const, IIFEs were the primary way to:
- Create a private scope and avoid polluting the global namespace
- Initialize libraries and plugins
- Wrap module code
const app = (function () {
let _privateState = 0;
return {
increment() { _privateState++; },
getState() { return _privateState; }
};
})();
app.increment();
console.log(app.getState()); // 1
Today, ES modules handle this naturally. But IIFEs still appear in bundled output and are worth understanding.
Part 2: Prototypes and Inheritance
The Prototype Chain
JavaScript is a prototype-based language. Every object has an internal link — [[Prototype]] — that points to another object. When you access a property, JavaScript first looks on the object itself, then walks up the chain.
const animal = {
breathe() {
return "breathing...";
}
};
const dog = Object.create(animal);
dog.bark = function () { return "woof!"; };
console.log(dog.bark()); // "woof!" — found on dog
console.log(dog.breathe()); // "breathing..." — found on animal (via prototype)
console.log(dog.toString()); // found on Object.prototype
The chain: dog → animal → Object.prototype → null
When the end (null) is reached without finding the property, undefined is returned (or a TypeError for method calls).
Object.create and Object.getPrototypeOf
Object.create(proto) creates a new object whose prototype is proto:
const vehicleProto = {
describe() {
return `I am a ${this.type} with ${this.wheels} wheels`;
}
};
const car = Object.create(vehicleProto);
car.type = "car";
car.wheels = 4;
console.log(car.describe()); // "I am a car with 4 wheels"
Object.getPrototypeOf(obj) lets you inspect the chain:
console.log(Object.getPrototypeOf(car) === vehicleProto); // true
Constructor Functions
Before ES6 classes, constructor functions were the way to create objects with shared behavior.
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.greet = function () {
return `Hi, I'm ${this.name}`;
};
const toqeer = new Person("Toqeer", 27);
console.log(toqeer.greet()); // "Hi, I'm Toqeer"
greet lives on Person.prototype, not on each instance — so all instances share it without duplication.
The new Keyword — What Actually Happens
When you call a function with new, four things happen under the hood:
1. A new empty object is created: {}
2. Its [[Prototype]] is linked to Constructor.prototype
3. `this` inside the function points to that new object
4. The function returns `this` (the new object) unless it explicitly returns another object
You can see this manually:
function myNew(Constructor, ...args) {
const obj = Object.create(Constructor.prototype); // step 1 & 2
const result = Constructor.apply(obj, args); // step 3
return result instanceof Object ? result : obj; // step 4
}
const person = myNew(Person, "Toqeer", 27);
console.log(person.greet()); // "Hi, I'm Toqeer"
This is exactly what new does — now it's not magic.
ES6 Classes — Syntactic Sugar
Classes in JavaScript are not a new object model. They're a cleaner syntax over the same prototype mechanism.
class Animal {
constructor(name) {
this.name = name;
}
speak() {
return `${this.name} makes a sound.`;
}
}
class Dog extends Animal {
speak() {
return `${this.name} barks.`;
}
}
const d = new Dog("Rex");
console.log(d.speak()); // "Rex barks."
console.log(d instanceof Animal); // true
Under the hood:
-
Animalis a constructor function -
speakis added toAnimal.prototype -
extendssets up the prototype chain:Dog.prototype → Animal.prototype -
super()calls the parent constructor
The output is identical to doing it manually with constructor functions and Object.create. Classes just make the intent clearer.
Key Differences from Constructor Functions
| Feature | Constructor Function | Class |
|---|---|---|
| Hoisted | Yes (as undefined) |
No (TDZ applies) |
| Strict mode | Optional | Always strict |
Callable without new
|
Yes (bad idea) | TypeError |
| Inherited methods | Manual prototype chain |
extends keyword |
super call |
Manual with .call
|
Built-in |
Putting It All Together
Here's everything combined — a real-world-ish example:
class EventEmitter {
#listeners = {};
on(event, handler) {
if (!this.#listeners[event]) this.#listeners[event] = [];
this.#listeners[event].push(handler);
return this; // for chaining
}
emit(event, ...args) {
(this.#listeners[event] || []).forEach(fn => fn(...args));
}
}
class Store extends EventEmitter {
#state;
constructor(initialState) {
super();
this.#state = initialState;
}
setState(newState) {
this.#state = { ...this.#state, ...newState };
this.emit("change", this.#state);
}
getState() {
return this.#state;
}
}
const store = new Store({ count: 0 });
store.on("change", (state) => {
console.log("State changed:", state);
});
store.setState({ count: 1 }); // "State changed: { count: 1 }"
This uses:
- Private class fields (
#listeners,#state) for encapsulation — closure-like privacy at the class level - Prototype inheritance via
extends -
super()to call the parent constructor - Method chaining via
return this
Quick Reference
Scoping Rules
-
var→ function-scoped, hoisted and initialized toundefined -
let/const→ block-scoped, hoisted but in TDZ until declaration -
const→ block-scoped + immutable binding (contents can mutate)
Closures
- Form when an inner function references outer variables
- The inner function holds a reference to the outer environment, not a copy
- Useful for: encapsulation, factories, memoization, event handlers
IIFE
- Runs immediately, creates isolated scope
(function() { ... })()- Less common today due to ES modules, but still found in bundled code
Prototype Chain
- Every object has a
[[Prototype]]link - Property lookup walks the chain until
null -
Object.create(proto)sets the prototype explicitly
new Keyword Steps
- Create empty object
- Link prototype to
Constructor.prototype - Execute constructor with
this= new object - Return the new object (unless constructor returns an object)
Classes
- Syntactic sugar over prototypes
-
extendswires prototype chain -
super()calls parent constructor - Always strict, not hoistable
Understanding these fundamentals will make you a better debugger, a better code reviewer, and a more confident engineer — whether you're writing Node.js APIs, React apps, or contributing to open source.
Drop a comment if you want me to go deeper on any of these. Happy to cover the event loop, async/await internals, or WeakMaps and memory management next.
Top comments (0)