DEV Community

M TOQEER ZIA
M TOQEER ZIA

Posted on

JavaScript Under the Hood: Scope, Closures, Prototypes &

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 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
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

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
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: doganimalObject.prototypenull

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:

  • Animal is a constructor function
  • speak is added to Animal.prototype
  • extends sets 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 }"
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 to undefined
  • 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

  1. Create empty object
  2. Link prototype to Constructor.prototype
  3. Execute constructor with this = new object
  4. Return the new object (unless constructor returns an object)

Classes

  • Syntactic sugar over prototypes
  • extends wires 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)