JavaScript Variables — var vs let vs const, Scope, and Hoisting Explained 2026

Sanjeev SharmaSanjeev Sharma
10 min read

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

Featurevarletconst
ScopeFunctionBlockBlock
HoistedYes (initialised to undefined)Yes (but in TDZ)Yes (but in TDZ)
Re-declarableYesNoNo
ReassignableYesYesNo (binding)
Global object propertyYes (in browser)NoNo
IntroducedES1ES2015ES2015

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 global

let — 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 j

const — 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 frozen

Scope 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)     // ❌ ReferenceError

Scope 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.Up

Common Mistakes

  • Using var in loops with closures — all callbacks share the same variable; use let.
  • Accessing let or const variables before their declaration — the Temporal Dead Zone throws a ReferenceError.
  • Expecting const to deeply freeze an object — const prevents rebinding, not mutation; use Object.freeze() for shallow immutability.
  • Re-declaring a let variable in the same scope — unlike var, this is a SyntaxError.
  • Using var at module level expecting it to be private — top-level var in browser scripts adds a property to window.

Best Practices

  • Default to const — only switch to let when you know the variable will be reassigned.
  • Never use var in new code — its function-scope and hoisting behaviour produce bugs that let/const prevent by design.
  • Name booleans as questions: isActive, hasPermission, canDelete.
  • Use SCREAMING_SNAKE_CASE for 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 const assertions on literal objects and arrays to narrow types to their exact values.

Key Takeaways

  • var is function-scoped and hoisted with initialisation to undefined; let and const are block-scoped and hoisted into the Temporal Dead Zone.
  • The Temporal Dead Zone means accessing a let or const variable before its declaration in the same block throws a ReferenceError.
  • const prevents reassignment of the binding but does not prevent mutation of the value it points to — use Object.freeze() for immutable objects.
  • let in a for loop creates a new binding per iteration, which is why closures inside let loops capture the correct value while var loops do not.
  • JavaScript resolves variables by walking up the scope chain from the innermost scope to the global scope.
  • Top-level var in browser scripts adds a property to the global window object; top-level let and const do not.
  • The recommended rule in 2026 is: use const by default, let when reassignment is needed, and never var.
  • TypeScript's as const assertion narrows a variable's type to its exact literal value, useful for configuration objects and string unions.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading