Updated June 2026. Tested on modern JavaScript (ES2020+). Part of the Techalyst JavaScript series.
A lot of confusing JavaScript bugs trace back to three fundamentals: how variables are scoped, when they come into existence (hoisting), and whether a value is copied or shared. Get these straight and a whole category of "but I didn't change that" moments disappears. Here is the practical version.
let and const versus var
Always use let and const; treat var as legacy. The difference is scope. let and const are block-scoped, they exist only inside the nearest { }. var is function-scoped and leaks out of blocks, which is rarely what you want.
for (let i = 0; i < 3; i++) { /* ... */ }
console.log(i) // ReferenceError, i stayed inside the loop (good)
for (var j = 0; j < 3; j++) { /* ... */ }
console.log(j) // 3, j leaked out of the loop (surprising)
Use const by default and let only when you genuinely reassign. const does not make objects immutable (more on that below), it just stops the variable being pointed at something else.
Hoisting and the temporal dead zone
Declarations are "hoisted" to the top of their scope, but let/const and var behave differently, which trips people up.
console.log(a) // ReferenceError, in the temporal dead zone
let a = 1
console.log(b) // undefined, var is hoisted and pre-initialised
var b = 2
A let declaration is hoisted but not initialised, so referencing it before its line throws (the temporal dead zone). A var is hoisted and quietly set to undefined, so you get a misleading value instead of an error. Function declarations are fully hoisted, name and body, so you can call them before they appear in the file.
greet() // works, fully hoisted
function greet() { console.log('hi') }
One practical consequence: if you assign inside a try block and need the value afterwards, declare the variable outside the block, because block scoping would otherwise hide it.
let result
try { result = await load() } catch (e) { /* ... */ }
console.log(result) // accessible
Primitive versus reference: the big one
This is the single most important idea here. JavaScript has two kinds of values, and they are copied differently.
Primitives (string, number, boolean, null, undefined, bigint, symbol) are copied by value. Each variable holds its own independent copy.
let a = 10
let b = a
b = 99
console.log(a) // 10, untouched
References (objects, arrays, functions) are copied by reference. The variable holds a pointer to one object in memory, so copying the variable copies the pointer, not the object.
const obj1 = { name: 'Aisha' }
const obj2 = obj1
obj2.name = 'Ravi'
console.log(obj1.name) // 'Ravi', both point at the same object
This is why a function can mutate an array you passed in, why two components sharing an object can surprise each other, and why const does not stop obj.name = ... (the variable still points at the same object, you only changed its contents). It is also the mechanism behind the singleton and factory patterns: a factory returns a brand-new object each call precisely so instances do not share a reference.
Copying without sharing
When you actually want an independent copy, spread does a shallow copy, top level is fresh, but nested objects are still shared.
const copy = { ...original } // shallow: nested objects still shared
const arrCopy = [...arr] // same for arrays
const merged = { ...defaults, ...overrides }
For a genuinely independent nested copy, use structuredClone(original) in modern environments, which deep-clones without the old JSON.parse(JSON.stringify(...)) hack and its limitations.
Safe access operators
Two operators prevent a lot of null-related crashes and are worth using everywhere.
user?.profile?.avatar // optional chaining: undefined instead of throwing
const name = input ?? 'Anonymous' // nullish coalescing: fallback only for null/undefined
Optional chaining short-circuits to undefined the moment any link is null or undefined, instead of throwing "cannot read property of undefined". Nullish coalescing (??) supplies a fallback only for null or undefined, unlike ||, which also triggers on 0, '', and false, so count ?? 10 keeps a real 0 while count || 10 would wrongly replace it.
Two typeof quirks to remember while we are here: typeof null is 'object' (a historic bug), and typeof [] is also 'object', so use Array.isArray(x) to test for an array.
Wrapping up
Use let and const for block scoping and avoid var, and respect the temporal dead zone by declaring before you use. The idea to truly internalise is primitive versus reference: primitives copy independently, objects and arrays share a pointer, which explains mutation surprises and underpins copying strategies. Reach for spread or structuredClone to copy deliberately, and lean on ?. and ?? for safe, predictable access.
More in the series: essential array methods and closures and the factory pattern. Questions welcome below.
All comments ()
No comments yet
Be the first to leave a comment on this post.