A Comparative Guide to JavaScript Variable Declarations: var, let, and const
Understanding Scope and Binding Rules in Modern JavaScript
JavaScript provides three distinct keywords for creating identifiers. Although they all allocate memory slots, their interactions with hoisting, reusability, and lexical boundaries differ fundamentally.
Legacy Declaration: var
The traditional mechanism relies on function-level scoping and exhibits unpredictable lookup patterns during compilation.
Function-Level Hoisting
Identifiers created with var are elevated to the top of their containing scope during the parse phase. Accessing them before assignment yields undefined instead of throwing an error.
// Output: undefined
console.log(switchState);
var switchState = true;
This behavior mirrors the internal engine transformation:
var switchState;
console.log(switchState); // undefined
switchState = true;
Redeclaration Flexibility
var allows multiple declarations of the same name within a single scope. Later executions simply overwrite earlier allocations.
var threshold = 50;
threshold = 90; // Direct overwrite across the same scope
console.log(threshold); // 90
Scope Boundary Leakage
Declaring inside a function restricts visibility. Omitting the keyword inadvertently attaches the identifier to the global object (window or globalThis).
var globalFlag = false;
function toggleConfig() {
// Implicitly targets the global environment
console.log(globalFlag); // false
globalFlag = true;
}
toggleConfig();
console.log(globalFlag); // true
Modern Block Scoping: let
Added in ES6, let enforces stricter lexical containment and eliminates accidental namespace pollution.
Strict Temporal Dead Zone (TDZ)
Referencing a let identifier before its definition line throws a ReferenceError. The slot exists from block entry until the initialization instruction runs.
// Throws: ReferenceError
console.log(loopIndex);
let loopIndex = 0;
Block-Level Encapsulation
Curly braces establish hard execution boundaries. External environments cannot perceive identifiers declared internally.
{
let sessionToken = "abc-123";
}
// Throws: ReferenceError
console.log(sessionToken);
Prohibition on Duplicate Declarations
The parser rejects any attempt to declare the same name twice within a shared lexical context, preventing ambiguous overrides.
{
let retryCount = 1;
// let retryCount = 5; // SyntaxError: Identifier 'retryCount' has already been declared
}
let retryCount = 2; // Permitted in the outer scope
console.log(retryCount); // 2
Nested Lookup Chains
Inner scopes inherit visibility of outer variables, but reverse access is forbidden. Child environments cannot leak into parent namespaces.
// Valid inner-to-outer access
function initializeData() {
let baseValue = 42;
if (true) {
console.log(baseValue); // 42
}
}
initializeData();
// Invalid outer access to inner state
function setupEnvironment() {
let parentId = 1;
console.log(parentId); // 1
function allocateChild() {
let childId = 2;
}
// Throws: ReferenceError
console.log(childId);
}
setupEnvironment();
Immutable Bindings: const
Apply const for identifiers that must maintain their initial reference throughout execution.
Read-Only Assignment Semantics
Reassigning a constant after creation immediately interrupts runtime execution.
const limit = 100;
limit = 150; // TypeError: Assignment to constant variable.
Mandatory Initialization at Definition
Omitting an initializer during declaration violates syntax rules.
const config; // SyntaxError
const config = { key: "value" }; // Valid
Distinguishing Reassignment from Mutation
const safeguards the binding itself, not the referenced data structure. Objects and arrays remain fully mutable unless explicitly frozen.
const registry = { version: 1 };
// Safe: modifying properties of the targeted object
registry.version = 2;
console.log(registry.version); // 2
// Forbidden: redirecting the identifier to a new object
registry = { version: 3 }; // TypeError