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;
Enter fullscreen mode Exit fullscreen mode
var is hoisted and initialized to undefined. So the engine sees this:
var name; // hoisted to top
console.log(name); // undefined
name = "Toqeer";
Enter fullscreen mode Exit fullscreen mode
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
}
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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
}
Enter fullscreen mode Exit fullscreen mode
let and const are block-scoped. They stay inside {}.
function checkStatus() {
if (true) {
let status = "active";
}
console.log(status); // ReferenceError
}
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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 the combination of a function and the lexical environment in which it was declared. That environment stays alive and accessible to the function — even after the outer scope that created it 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
Enter fullscreen mode Exit fullscreen mode
makeCounter has long since returned. Its execution context is gone. But count lives on — because the inner function closed over it.
How Closures Form
Every function has an associated closure, created automatically at the moment the function itself is created. This happens for every function — not just ones nested inside another function — and whether or not the function body ever references a variable from an outer scope. Nesting a function inside another simply makes the closure easy to observe, because the inner function can then reach variables in the outer function’s scope that would otherwise be gone once the outer function returns. The function holds a reference to its lexical environment, not a copy of the variables in it.
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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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);
});
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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"
Enter fullscreen mode Exit fullscreen mode
Object.getPrototypeOf(obj) lets you inspect the chain:
console.log(Object.getPrototypeOf(car) === vehicleProto); // true
Enter fullscreen mode Exit fullscreen mode
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"
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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"
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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 (asundefined)
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 }"
Enter fullscreen mode Exit fullscreen mode
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
- Every function has a closure, formed automatically at creation time — nesting and referencing outer variables aren’t required, they just make the closure observable
- The function holds a reference to its lexical 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.
답글 남기기