JavaScript Variables — var vs let vs const, Scope, and Hoisting Explained 2026
Advertisement
Introduction
Why This Matters
Variables are the most fundamental building block in any programming language, and JavaScript has three ways to declare them — each with different rules for scope, hoisting, mutability, and temporal availability. Getting this wrong produces one of the most insidious categories of JavaScript bugs: code that runs without throwing an error but produces incorrect results. The var-in-a-loop closure bug, temporal dead zone errors, and accidental global pollution are all rooted in misunderstanding variable declaration semantics. This guide explains all of it precisely.
The Three Declaration Keywords at a Glance
| Feature | var | let | const |
|---|---|---|---|
| Scope | Function | Block | Block |
| Hoisted | Yes (initialised to undefined) | Yes (but in TDZ) | Yes (but in TDZ) |
| Re-declarable | Yes | No | No |
| Reassignable | Yes | Yes | No (binding) |
| Global object property | Yes (in browser) | No | No |
| Introduced | ES1 | ES2015 | ES2015 |
The recommendation in 2026: use const by default, let when reassignment is needed, and never use var in new code.
var — Function Scope and Hoisting
var was JavaScript's only declaration keyword until ES2015. Its unusual scoping rules cause frequent bugs:
// var is function-scoped — not block-scoped
function example() {
if (true) {
var inside = 'I am inside the if block'
}
console.log(inside) // 'I am inside the if block' — var leaks out of blocks!
}
// var is hoisted with initialisation to undefined
console.log(greeting) // undefined — NOT an error (hoisted)
var greeting = 'Hello'
console.log(greeting) // 'Hello'
// The hoisting equivalent:
var greeting // declaration hoisted to top of function
console.log(greeting) // undefined
greeting = 'Hello'
console.log(greeting) // 'Hello'
// var can be re-declared without error — a source of silent bugs
var user = 'Alice'
var user = 'Bob' // no error, silently overwrites
console.log(user) // 'Bob'In browsers, var at the top level adds a property to window — a global pollution problem:
var polluted = true
console.log(window.polluted) // true — unintended globallet — Block Scope with Temporal Dead Zone
let fixes var's scoping problems by confining variables to the block they are declared in:
function example() {
if (true) {
let blockScoped = 'only here'
console.log(blockScoped) // 'only here' ✅
}
console.log(blockScoped) // ❌ ReferenceError: blockScoped is not defined
}
// let is hoisted but NOT initialised — the Temporal Dead Zone (TDZ)
console.log(x) // ❌ ReferenceError: Cannot access 'x' before initialization
let x = 10
// The TDZ exists from the start of the block until the declaration is reached
// let cannot be re-declared in the same scope
let name = 'Alice'
let name = 'Bob' // ❌ SyntaxError: Identifier 'name' has already been declared
// Each loop iteration gets its own binding
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100)
}
// 0, 1, 2 — because let creates a new i binding per iteration
// Compare with var:
for (var j = 0; j < 3; j++) {
setTimeout(() => console.log(j), 100)
}
// 3, 3, 3 — all closures share the same jconst — Block Scope, Immutable Binding
const declares a block-scoped variable whose binding cannot be reassigned. The value it points to can still be mutated if it is a reference type:
const PI = 3.14159
PI = 3 // ❌ TypeError: Assignment to constant variable
const user = { name: 'Alice', role: 'admin' }
user = { name: 'Bob' } // ❌ TypeError — cannot reassign the binding
user.name = 'Bob' // ✅ — mutating the object is allowed
user.permissions = ['read'] // ✅ — adding properties is allowed
const tags = ['javascript', 'typescript']
tags = [] // ❌ TypeError
tags.push('react') // ✅ — mutating the array is allowed
tags[0] = 'python' // ✅
// To prevent mutation of the value itself:
const frozen = Object.freeze({ name: 'Alice', age: 30 })
frozen.age = 99 // Silently fails in sloppy mode, throws in strict mode
console.log(frozen.age) // still 30
// Object.freeze is shallow — nested objects are still mutable
const shallow = Object.freeze({ address: { city: 'NYC' } })
shallow.address.city = 'LA' // ✅ — nested object is not frozenScope in Depth
JavaScript has three types of scope:
// 1. Global scope — accessible everywhere
const APP_VERSION = '3.1.0'
// 2. Function scope — local to the function
function calculateTotal(price, qty) {
const subtotal = price * qty // local — not visible outside
const tax = subtotal * 0.08
return subtotal + tax
}
console.log(subtotal) // ❌ ReferenceError
// 3. Block scope — local to the { } block (let and const only)
{
const secret = 'block-scoped'
console.log(secret) // 'block-scoped'
}
console.log(secret) // ❌ ReferenceErrorScope chain: when JavaScript resolves a variable, it starts in the current scope and walks outward through enclosing scopes until it finds the variable or hits the global scope:
const level = 'global'
function outer() {
const level = 'outer'
function inner() {
const level = 'inner'
console.log(level) // 'inner' — found in own scope
}
function lookup() {
console.log(level) // 'outer' — not found locally, walks up to outer()
}
inner() // 'inner'
lookup() // 'outer'
}
outer()Hoisting in Detail
Hoisting is JavaScript's behaviour of moving declarations to the top of their scope before execution. Understanding what is and is not initialised during hoisting is essential:
// var — hoisted AND initialised to undefined
console.log(a) // undefined (not an error)
var a = 5
console.log(a) // 5
// let / const — hoisted but NOT initialised (Temporal Dead Zone)
console.log(b) // ❌ ReferenceError (TDZ)
let b = 5
// Function DECLARATIONS — fully hoisted (name + body)
sayHello() // ✅ Works — function declaration is fully hoisted
function sayHello() { console.log('Hello!') }
// Function EXPRESSIONS — only the variable is hoisted, not the value
sayBye() // ❌ TypeError: sayBye is not a function (var is undefined)
var sayBye = function() { console.log('Bye!') }
// Arrow functions assigned to const — TDZ applies
greet() // ❌ ReferenceError
const greet = () => console.log('Hi!')Destructuring Assignment
Destructuring works with both let and const and is the idiomatic way to unpack values:
// Array destructuring
const [first, second, ...rest] = [1, 2, 3, 4, 5]
console.log(first) // 1
console.log(rest) // [3, 4, 5]
// Swap values without a temp variable
let x = 1, y = 2
;[x, y] = [y, x]
console.log(x, y) // 2, 1
// Object destructuring with defaults and renaming
const { name: userName = 'Guest', age: userAge = 0, role = 'user' } = apiResponse
// Renames name → userName, age → userAge, role uses the key as-is
// Nested destructuring
const { address: { city, country = 'US' } } = user
// In function parameters
function renderCard({ title, description, tags = [], featured = false }) {
return `${title}: ${description} (${tags.join(', ')})`
}Variable Naming Conventions
// camelCase — standard for variables and functions
const userName = 'Alice'
const totalPrice = 99.99
const isLoggedIn = true
function fetchUserData() {}
// SCREAMING_SNAKE_CASE — for true constants (magic numbers, config)
const MAX_RETRIES = 3
const API_BASE_URL = 'https://api.example.com'
const JWT_EXPIRES_IN = '1h'
// PascalCase — for classes and React components
class UserService {}
function UserCard({ user }) {}
// Private class fields — prefix with #
class Counter {
#count = 0
increment() { this.#count++ }
get value() { return this.#count }
}
// Avoid: single letters (except loop counters), abbreviations, misleading names
let d = new Date() // ❌ — what is d?
let currentDate = new Date() // ✅
// Boolean variables should read as questions
const isActive = true
const hasPermission = false
const canDelete = user.role === 'admin'TypeScript Variable Declarations
TypeScript adds compile-time enforcement on top of JavaScript variable rules:
// Type annotations (optional when TypeScript can infer)
const name: string = 'Alice'
let count: number = 0
const active: boolean = true
// TypeScript infers from assignment — explicit annotation optional
const inferred = 'hello' // TypeScript knows: string
// inferred = 42 // ❌ Error: Type 'number' is not assignable to type 'string'
// const assertions — narrow the type to the literal value
const STATUS = 'active' as const // type: 'active', not string
const CONFIG = { port: 3000 } as const // type: { readonly port: 3000 }
// Tuple — fixed-length array with specific types per position
const pair: [string, number] = ['Alice', 30]
const [pairName, pairAge] = pair // pairName: string, pairAge: number
// Enum — named constant group
enum Direction { Up = 'UP', Down = 'DOWN', Left = 'LEFT', Right = 'RIGHT' }
const heading: Direction = Direction.UpCommon Mistakes
- Using
varin loops with closures — all callbacks share the same variable; uselet. - Accessing
letorconstvariables before their declaration — the Temporal Dead Zone throws aReferenceError. - Expecting
constto deeply freeze an object —constprevents rebinding, not mutation; useObject.freeze()for shallow immutability. - Re-declaring a
letvariable in the same scope — unlikevar, this is aSyntaxError. - Using
varat module level expecting it to be private — top-levelvarin browser scripts adds a property towindow.
Best Practices
- Default to
const— only switch toletwhen you know the variable will be reassigned. - Never use
varin new code — its function-scope and hoisting behaviour produce bugs thatlet/constprevent by design. - Name booleans as questions:
isActive,hasPermission,canDelete. - Use
SCREAMING_SNAKE_CASEfor genuine constants (configuration, magic numbers) to signal to other developers that these values should never change. - Prefer destructuring over accessing nested properties one by one — it is shorter and makes dependencies explicit.
- In TypeScript, use
as constassertions on literal objects and arrays to narrow types to their exact values.
Key Takeaways
varis function-scoped and hoisted with initialisation toundefined;letandconstare block-scoped and hoisted into the Temporal Dead Zone.- The Temporal Dead Zone means accessing a
letorconstvariable before its declaration in the same block throws aReferenceError. constprevents reassignment of the binding but does not prevent mutation of the value it points to — useObject.freeze()for immutable objects.letin aforloop creates a new binding per iteration, which is why closures insideletloops capture the correct value whilevarloops do not.- JavaScript resolves variables by walking up the scope chain from the innermost scope to the global scope.
- Top-level
varin browser scripts adds a property to the globalwindowobject; top-levelletandconstdo not. - The recommended rule in 2026 is: use
constby default,letwhen reassignment is needed, and nevervar. - TypeScript's
as constassertion narrows a variable's type to its exact literal value, useful for configuration objects and string unions.
Advertisement