DEV Community

Surya Kanth
Surya Kanth

Posted on

Under the Hood of JavaScript: Demystifying Hoisting, Objects, 'this', and the 'new' Keyword

You know that moment when JavaScript does something so weird you start suspecting your laptop is haunted?

console.log(name); // undefined... not an error?!
var name = "Ada";
Enter fullscreen mode Exit fullscreen mode

Or when this is your object in one line and undefined in the next, and you didn't change anything?

JavaScript isn't random. It follows a small set of rules, and nobody explains them well on day one. Once these four ideas click, a huge chunk of "JS magic" turns into "oh, that makes sense":

  1. Hoisting
  2. Objects & references
  3. this
  4. The new keyword

Grab a coffee. Let's pop the hood. ☕


1. Hoisting: JavaScript Reads Your Code Twice

Here's the mental model that fixes most confusion:

Before JavaScript runs your code, it does a quick prep pass called the creation phase. During it, JS scans the scope and registers your declarations in memory. Then the execution phase runs your code line by line.

Hoisting is just a side effect of that prep pass. Your code isn't physically moved anywhere. Declarations are registered before execution begins.

┌─────────────────────────────────────────────┐
│  Execution Context                          │
│                                             │
│  PHASE 1: CREATION      PHASE 2: EXECUTION  │
│  ─────────────────      ─────────────────   │
│  • find declarations    • run line by line  │
│  • set aside memory     • assign values     │
│  • set initial state    • call functions    │
└─────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

What each declaration gets during the creation phase depends on how you declared it.

Functions, var, and let/const side by side

Declaration Hoisted? Initial value during creation Usable before its line?
function foo() {} ✅ The whole function ✅ Yes
var x ✅ undefined ⚠️ Yes, but it's undefined
let / const ✅ Uninitialized (in the TDZ) ❌ ReferenceError
class Foo {} ✅ Uninitialized (in the TDZ) ❌ ReferenceError

Function declarations: fully hoisted

sayHi(); // "Hi there!" ✅ works before the declaration

function sayHi() {
  console.log("Hi there!");
}
Enter fullscreen mode Exit fullscreen mode

The entire function, body included, is stored during the creation phase. That's why it's callable from anywhere in its scope.

var: hoisted, but only the name

console.log(coffee); // undefined (not an error!)
var coffee = "espresso";
console.log(coffee); // "espresso"
Enter fullscreen mode Exit fullscreen mode

JS sees var coffee, creates the variable, and sets it to undefined. The = "espresso" part only happens during execution. Behind the scenes it's like this:

var coffee;            // creation phase: declared, value is undefined
console.log(coffee);   // undefined
coffee = "espresso";   // execution phase: assignment
Enter fullscreen mode Exit fullscreen mode

let and const: hoisted, but locked (the Temporal Dead Zone)

Plain-English version of the Temporal Dead Zone (TDZ):

Imagine a restaurant reserves a table with your name on it. The table exists, but you can't sit down until your party arrives. Touch it early and the staff yells at you.

For let and const, the variable is registered in the creation phase, but it stays off-limits until execution reaches its declaration line. The stretch between the start of the scope and that line is the Temporal Dead Zone.

console.log(tea); // ❌ ReferenceError: Cannot access 'tea' before initialization
let tea = "green";
console.log(tea); // "green" ✅
Enter fullscreen mode Exit fullscreen mode

The error message says "cannot access before initialization", not "tea is not defined". That's proof tea was hoisted. JS knows about it but refuses to let you touch it yet.

scope starts
   │   ← TDZ: `tea` exists but is untouchable
   │
let tea = "green";   ← TDZ ends here
   │   ← safe to use
Enter fullscreen mode Exit fullscreen mode

Gotchas that show up in interviews 🎯

Gotcha #1: Function expressions are NOT hoisted like declarations

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

var greet = function () {
  console.log("Hello!");
};
Enter fullscreen mode Exit fullscreen mode

Only the variable greet is hoisted (as undefined). Calling undefined() gives you a TypeError. With let/const you'd get a ReferenceError instead.

Gotcha #2: The classic var in a loop

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// Prints: 3, 3, 3 😱
Enter fullscreen mode Exit fullscreen mode

var is function-scoped, so there's one shared i. By the time the timeouts fire, the loop is finished and i is 3. Swap in let and each iteration gets its own i:

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// Prints: 0, 1, 2 ✅
Enter fullscreen mode Exit fullscreen mode

Gotcha #3: Function declarations beat var on priority

console.log(typeof thing); // "function"

var thing = "I'm a string";
function thing() {}
Enter fullscreen mode Exit fullscreen mode

Function declarations win during the creation phase, so thing starts as a function. The var assignment only overwrites it later, during execution.

💡 Rule of thumb: Use const by default, let when you need to reassign, and skip var. The TDZ is a feature. It turns silent bugs into loud errors.


2. Objects & References: Boxes vs. Address Labels

Object literal basics

An object is a bundle of key-value pairs. The literal syntax is the quickest way to make one:

const dev = {
  name: "Ada",                    // property
  skills: ["JS", "CSS"],          // properties can hold any type
  greet() {                       // method shorthand (ES6)
    return `Hi, I'm ${this.name}`;
  },
};

console.log(dev.name);        // "Ada" (dot notation)
console.log(dev["skills"]);   // ["JS", "CSS"] (bracket notation)
Enter fullscreen mode Exit fullscreen mode

Primitives vs. references

JavaScript has two families of values:

  • Primitives: string, number, boolean, null, undefined, symbol, bigint
  • Objects (which include arrays and functions)

They behave very differently when copied.

Primitives are copied by value. You get an independent copy:

let a = 10;
let b = a;   // b gets its own copy of 10
b = 99;

console.log(a); // 10 ✅ untouched
Enter fullscreen mode Exit fullscreen mode

Objects are copied by reference. You copy the address, not the object:

const original = { score: 10 };
const copy = original;   // copies the reference (address), NOT the object
copy.score = 99;

console.log(original.score); // 99 😱 same object!
Enter fullscreen mode Exit fullscreen mode

Here's the picture:

PRIMITIVES                       OBJECTS

 a ─► [ 10 ]                     original ──┐
 b ─► [ 99 ]   (separate boxes)             ├──► { score: 99 }  (one object)
                                 copy ──────┘   (two labels, same address)
Enter fullscreen mode Exit fullscreen mode

Think of it this way:

A primitive variable holds the value itself. An object variable holds a sticky note with an address. Copying the variable copies the sticky note, not the house.

Things this explains

const doesn't make objects immutable. It locks the variable's binding (the sticky note), not the object's contents:

const user = { name: "Ada" };
user.name = "Grace";   // ✅ allowed: we mutated the object
user = {};             // ❌ TypeError: can't reassign the binding
Enter fullscreen mode Exit fullscreen mode

Objects compare by reference, not by content:

console.log({ a: 1 } === { a: 1 }); // false: two different objects
const x = { a: 1 };
const y = x;
console.log(x === y);               // true: same address
Enter fullscreen mode Exit fullscreen mode

Want a real copy? Spread it (shallow) or use structuredClone (deep):

const clone = { ...original };                // new object, same top-level values
const deepClone = structuredClone(original);  // modern deep copy
Enter fullscreen mode Exit fullscreen mode

3. Enter this: The Shapeshifter 🎭

Here's the sentence to tattoo on your brain:

this is NOT determined by where a function is written. It's determined by how the function is called, at the call-site.

Same function, different call, different this. There are four binding rules, plus one exception (arrow functions).

Rule 1: Default binding (the "nobody owns this call" rule)

A plain function call with no object in front of it:

"use strict";

function whoAmI() {
  console.log(this);
}

whoAmI(); // undefined (strict mode / modules)
Enter fullscreen mode Exit fullscreen mode

In non-strict code, this falls back to the global object (window in browsers, globalThis elsewhere). That's how accidental globals happen. ES modules and classes are strict by default, so you'll usually get undefined, which fails loudly.

Rule 2: Implicit binding (the "dot rule")

If a function is called with an object in front of it, this is that object:

const dev = {
  name: "Ada",
  hi() {
    console.log(`Hi, I'm ${this.name}`);
  },
};

dev.hi(); // "Hi, I'm Ada"  ← `dev` is left of the dot, so this === dev
Enter fullscreen mode Exit fullscreen mode

Quick trick: look left of the dot at the call-site. That's your this.

The classic trap: the lost this

const hi = dev.hi;   // we grabbed the function and detached it from `dev`
hi();                // ❌ "Hi, I'm undefined" (default binding kicks in!)
Enter fullscreen mode Exit fullscreen mode

The function is identical, but the call-site changed from dev.hi() to a bare hi(). This is why passing methods as callbacks (setTimeout(dev.hi, 100)) breaks so often.

Rule 3: Explicit binding (call, apply, bind)

Don't like what this is? Tell JS what it should be.

function intro(role, company) {
  console.log(`${this.name}, ${role} @ ${company}`);
}

const ada = { name: "Ada" };

// call: invoke now, arguments listed one by one
intro.call(ada, "Engineer", "Analytical Inc.");

// apply: invoke now, arguments in an array
intro.apply(ada, ["Engineer", "Analytical Inc."]);

// bind: DON'T invoke, return a NEW function with `this` locked in
const adaIntro = intro.bind(ada, "Engineer");
adaIntro("Analytical Inc."); // "Ada, Engineer @ Analytical Inc."
Enter fullscreen mode Exit fullscreen mode

Memory hook: Call = Comma-separated args. Apply = Array. Bind = Builds a new function for later.

Bind fixes the lost this problem from above:

const safeHi = dev.hi.bind(dev);
safeHi(); // "Hi, I'm Ada" ✅
Enter fullscreen mode Exit fullscreen mode

Rule 4: new binding

Call a function with new and this becomes a brand-new object. We'll dig into that in the next section. 👇

Which rule wins when several apply?

1. new binding       ← strongest
2. explicit binding  (call / apply / bind)
3. implicit binding  (obj.method())
4. default binding   ← weakest fallback
Enter fullscreen mode Exit fullscreen mode

The exception: arrow functions and lexical this

Arrow functions don't have their own this. They borrow it from the surrounding scope where they were written. That's what "lexical this" means.

const timer = {
  seconds: 0,
  start() {
    // `this` here is `timer` (implicit binding)
    setInterval(() => {
      this.seconds++;            // ✅ arrow inherits `this` from start()
      console.log(this.seconds);
    }, 1000);
  },
};

timer.start();
Enter fullscreen mode Exit fullscreen mode

With a regular function () {} inside setInterval, this would be lost (default binding). The arrow function solves that.

But don't use arrows as object methods:

const broken = {
  name: "Ada",
  hi: () => console.log(this.name), // ❌ `this` is NOT `broken`; it's the outer scope's this
};
Enter fullscreen mode Exit fullscreen mode

Arrow functions also ignore call, apply, and bind for this, and they can't be used with new.

🧠 Cheat sheet: Regular function? Look at the call-site. Arrow function? Look at where it was written.


4. Constructor Functions & the new Keyword 🏭

Why did we need constructor functions?

Before ES6 class, suppose you needed 100 similar objects. Copy-pasting object literals gets old fast:

const dev1 = { name: "Ada", role: "Engineer" };
const dev2 = { name: "Grace", role: "Admiral" };
// ...98 more. Nope.
Enter fullscreen mode Exit fullscreen mode

The pre-ES6 answer was the constructor function: a regular function that acts as a blueprint, by convention capitalized, and called with new:

function Developer(name, role) {
  this.name = name;   // `this` will be the new object
  this.role = role;
}

const ada = new Developer("Ada", "Engineer");
console.log(ada); // Developer { name: 'Ada', role: 'Engineer' }
Enter fullscreen mode Exit fullscreen mode

(ES6 class is mostly friendlier syntax on top of this same machinery, so understanding it here pays off.)

What new does: the 4 steps

When you write new Developer("Ada", "Engineer"), JavaScript does this:

   new Developer("Ada", "Engineer")
            │
            ▼
 ┌────────────────────────────────────────────────┐
 │ STEP 1  Create a brand-new empty object   {}   │
 ├────────────────────────────────────────────────┤
 │ STEP 2  Link its prototype:                    │
 │         obj.__proto__ = Developer.prototype    │
 ├────────────────────────────────────────────────┤
 │ STEP 3  Run Developer with `this` = that obj   │
 │         (the new binding rule!)                │
 ├────────────────────────────────────────────────┤
 │ STEP 4  Return the object (unless the function │
 │         explicitly returns a different object) │
 └────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Don't take my word for it: build new yourself

function myNew(Constructor, ...args) {
  // STEP 1 + 2: create an empty object linked to the constructor's prototype
  const obj = Object.create(Constructor.prototype);

  // STEP 3: run the constructor with `this` bound to our new object
  const result = Constructor.apply(obj, args);

  // STEP 4: return the object (or an object the constructor returned explicitly)
  const returnedAnObject =
    (typeof result === "object" && result !== null) ||
    typeof result === "function";
  return returnedAnObject ? result : obj;
}
Enter fullscreen mode Exit fullscreen mode

Now test it against the real thing:

function Developer(name, role) {
  this.name = name;
  this.role = role;
}

// Methods go on the prototype so ALL instances share ONE copy
Developer.prototype.intro = function () {
  return `${this.name} the ${this.role}`;
};

const real = new Developer("Ada", "Engineer");
const fake = myNew(Developer, "Grace", "Admiral");

console.log(real.intro());                        // "Ada the Engineer"
console.log(fake.intro());                        // "Grace the Admiral"
console.log(fake instanceof Developer);           // true
console.log(Object.getPrototypeOf(fake) === Developer.prototype); // true
Enter fullscreen mode Exit fullscreen mode

Same behavior from about 8 lines of code. That's all new is doing.

Why put methods on the prototype?

function Bad(name) {
  this.name = name;
  this.hi = function () {};   // ❌ a NEW function copy for every instance
}

function Good(name) {
  this.name = name;
}
Good.prototype.hi = function () {}; // ✅ one shared function, all instances link to it
Enter fullscreen mode Exit fullscreen mode

When you call ada.intro(), JS looks on ada first, doesn't find it, then follows the prototype link to Developer.prototype. That lookup chain is the prototype chain.

The #1 constructor bug: forgetting new

const oops = Developer("Ada", "Engineer"); // 😬 no `new`!
Enter fullscreen mode Exit fullscreen mode

Without new, this is a plain function call, so default binding applies. In sloppy mode, this is the global object and you just polluted window.name. In strict mode you get a TypeError. That's one more reason to write strict code, or to use class, which throws if you forget new.


5. Putting It All Together: 10 Lines, 4 Concepts

"use strict";
console.log(typeof Dev);                   // "function" → hoisting: whole function registered early
function Dev(name) { this.name = name; }   // constructor: `this` = new object when called with `new`
Dev.prototype.hi = function () { return `Hi, ${this.name}`; }; // shared method via prototype
const ada = new Dev("Ada");                // new: create → link prototype → bind this → return
const team = { lead: ada };                // object literal holding a *reference* to ada
console.log(team.lead === ada);            // true → same address, not a copy
console.log(ada.hi());                     // "Hi, Ada" → implicit binding (left of the dot)
console.log(ada.hi.call({ name: "Grace" })); // "Hi, Grace" → explicit binding overrides `this`
const loose = ada.hi; try { loose(); } catch { console.log("this got lost 💥"); } // default binding
Enter fullscreen mode Exit fullscreen mode

Count them: hoisting (line 2), new binding (line 5), references (lines 6–7), implicit (line 8), explicit (line 9), and default binding (line 10, where strict mode makes it fail loudly).


Wrap-Up 🎁

Here's the whole article in five bullets:

  • Hoisting is the creation phase registering declarations before execution. Functions are fully hoisted, var starts as undefined, and let/const sit in the TDZ until their line runs.
  • Objects are references. Copying an object variable copies the address, not the object. const locks the binding, not the contents.
  • this is decided at the call-site, in this order: new → explicit (call/apply/bind) → implicit (obj.method()) → default.
  • Arrow functions have no this of their own and use the surrounding one. Great for callbacks, bad for object methods.
  • new creates an object, links its prototype, binds this, and returns it. It's four steps, no magic.

💬 Your turn!

I'd love to hear from you in the comments:

What's the weirdest this or hoisting bug you've ever hit in production? Bonus points if it took you more than an hour to find. 😅

And if you're prepping for interviews, drop the JS concept that still trips you up, and we can tackle it together in a follow-up post.

Happy coding! 🚀

Top comments (0)