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";
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":
- Hoisting
- Objects & references
this- The
newkeyword
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 │
└─────────────────────────────────────────────┘
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!");
}
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"
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
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" ✅
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
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!");
};
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 😱
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 ✅
Gotcha #3: Function declarations beat var on priority
console.log(typeof thing); // "function"
var thing = "I'm a string";
function thing() {}
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
constby default,letwhen you need to reassign, and skipvar. 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)
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
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!
Here's the picture:
PRIMITIVES OBJECTS
a ─► [ 10 ] original ──┐
b ─► [ 99 ] (separate boxes) ├──► { score: 99 } (one object)
copy ──────┘ (two labels, same address)
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
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
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
3. Enter this: The Shapeshifter 🎭
Here's the sentence to tattoo on your brain:
thisis 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)
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
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!)
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."
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" ✅
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
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();
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
};
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.
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' }
(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) │
└────────────────────────────────────────────────┘
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;
}
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
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
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`!
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
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,
varstarts asundefined, andlet/constsit in the TDZ until their line runs. -
Objects are references. Copying an object variable copies the address, not the object.
constlocks the binding, not the contents. -
thisis decided at the call-site, in this order:new→ explicit (call/apply/bind) → implicit (obj.method()) → default. -
Arrow functions have no
thisof their own and use the surrounding one. Great for callbacks, bad for object methods. -
newcreates an object, links its prototype, bindsthis, 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
thisor 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)