JavaScript Series #52: Let and Const vs. Var - Revisited
In the ever-evolving landscape of JavaScript, understanding how we declare variables is fundamental. While var has been a staple since the language's inception, the introduction of let and const in ECMAScript 2015 (ES6) brought about significant improvements in how we manage variable scope and immutability. This post revisits these three keywords, dissecting their unique behaviors and clarifying why let and const are now the preferred choice for modern JavaScript development.
The Legacy: var
Before ES6, var was the only way to declare variables in JavaScript. It comes with certain characteristics that can sometimes lead to unexpected behavior, especially for developers coming from other languages.
Function Scope
Variables declared with var are function-scoped. This means they are only accessible within the function they are declared in, or globally if declared outside any function. They do not respect block scopes like if statements or for loops.
function demonstrateVarScope() {
if (true) {
var functionScopedVar = "I am function-scoped!";
console.log(functionScopedVar); // Output: I am function-scoped!
}
console.log(functionScopedVar); // Output: I am function-scoped! (Accessible outside the if block)
}
demonstrateVarScope();
// console.log(functionScopedVar); // ReferenceError: functionScopedVar is not defined (Not accessible outside the function)
var globalVar = "I am global!";
console.log(globalVar); // Output: I am global!
Hoisting Behavior
var declarations are hoisted to the top of their enclosing function or global scope. This means you can reference a var variable before its declaration in the code, though its initial value will be undefined.
console.log(hoistedVar); // Output: undefined
var hoistedVar = "I was hoisted!";
console.log(hoistedVar); // Output: I was hoisted!
// This is interpreted by the JavaScript engine as:
// var hoistedVar;
// console.log(hoistedVar);
// hoistedVar = "I was hoisted!";
// console.log(hoistedVar);
Redeclaration and Reassignment
var variables can be easily redeclared and reassigned within the same scope without any errors. This flexibility, while sometimes convenient, can also lead to bugs where a variable is accidentally redeclared, overwriting a previous value.
var message = "Hello";
console.log(message); // Output: Hello
var message = "World"; // Redeclared
console.log(message); // Output: World
message = "JavaScript"; // Reassigned
console.log(message); // Output: JavaScript
Common Pitfalls with var
One of the most common issues with var arises in loops, where the variable is function-scoped instead of block-scoped.
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // This will log '3' three times, not 0, 1, 2
}, 100);
}
// This happens because 'i' is function-scoped (or global here) and by the time setTimeout callbacks execute,
// the loop has already finished and 'i' has been incremented to 3.
The Evolution: let
Introduced in ES6, let provides a more predictable way to declare variables by addressing many of var's quirks.
Block Scope
let variables are block-scoped. This means they are only accessible within the block (code surrounded by curly braces {}) where they are declared. This includes if blocks, for loops, and any other code block.
function demonstrateLetScope() {
if (true) {
let blockScopedLet = "I am block-scoped!";
console.log(blockScopedLet); // Output: I am block-scoped!
}
// console.log(blockScopedLet); // ReferenceError: blockScopedLet is not defined (Not accessible outside the if block)
}
demonstrateLetScope();
for (let j = 0; j < 3; j++) {
// j is only accessible within this loop block
}
// console.log(j); // ReferenceError: j is not defined
Temporal Dead Zone (TDZ)
While let declarations are also "hoisted" in a sense (their existence is known by the engine before execution), they are not initialized with undefined. Instead, they enter a Temporal Dead Zone (TDZ) from the start of their scope until their declaration is encountered. Accessing a let variable within the TDZ will result in a ReferenceError.
// console.log(tDZoneLet); // ReferenceError: Cannot access 'tDZoneLet' before initialization
let tDZoneLet = "I am out of the TDZ!";
console.log(tDZoneLet); // Output: I am out of the TDZ!
No Redeclaration, But Reassignment Allowed
You cannot redeclare a let variable within the same scope. However, you can reassign its value.
let userName = "Alice";
console.log(userName); // Output: Alice
// let userName = "Bob"; // SyntaxError: Identifier 'userName' has already been declared
// This error prevents accidental overwrites.
userName = "Charlie"; // Reassignment is allowed
console.log(userName); // Output: Charlie
The Constant: const
Also introduced in ES6, const is used for declaring variables whose values are intended to remain constant throughout the program.
Block Scope and TDZ
Like let, const variables are block-scoped and subject to the Temporal Dead Zone (TDZ). They must be initialized at the time of declaration.
// console.log(myConst); // ReferenceError: Cannot access 'myConst' before initialization
// const myOtherConst; // SyntaxError: Missing initializer in const declaration
const myConst = "I am a constant!";
console.log(myConst); // Output: I am a constant!
No Redeclaration, No Reassignment for Primitives
The primary characteristic of const is that it prevents both redeclaration and reassignment for primitive values (strings, numbers, booleans, null, undefined, symbols, bigints). Once assigned, their value cannot be changed.
const PI = 3.14159;
console.log(PI); // Output: 3.14159
// PI = 3.14; // TypeError: Assignment to constant variable.
// const PI = 3.0; // SyntaxError: Identifier 'PI' has already been declared
Working with Objects and Arrays
It's crucial to understand that const does not make the value immutable; it makes the binding immutable. For objects and arrays, this means you cannot reassign the variable to point to a different object or array, but you can mutate the properties of the object or the elements of the array.
const user = { name: "John", age: 30 };
console.log(user); // Output: { name: 'John', age: 30 }
user.age = 31; // Mutating an object property is allowed
console.log(user); // Output: { name: 'John', age: 31 }
// user = { name: "Jane", age: 25 }; // TypeError: Assignment to constant variable. (Reassignment of the variable is not allowed)
const numbers = [1, 2, 3];
console.log(numbers); // Output: [1, 2, 3]
numbers.push(4); // Mutating array content is allowed
console.log(numbers); // Output: [1, 2, 3, 4]
// numbers = [5, 6, 7]; // TypeError: Assignment to constant variable.
Summary of Key Differences
Here's a concise overview of the distinctions:
- Scope:
var: Function-scoped.let: Block-scoped.const: Block-scoped.
- Hoisting:
var: Hoisted and initialized withundefined.let: Hoisted but in a Temporal Dead Zone (TDZ) until declaration (causesReferenceErrorif accessed before).const: Hoisted but in a Temporal Dead Zone (TDZ) until declaration (causesReferenceErrorif accessed before). Must be initialized on declaration.
- Redeclaration:
var: Allowed within the same scope.let: Not allowed within the same scope (SyntaxError).const: Not allowed within the same scope (SyntaxError).
- Reassignment:
var: Allowed.let: Allowed.const: Not allowed for primitive values. For objects/arrays, the binding cannot be reassigned, but the value's internal properties/elements can be mutated.
When to Use Which: Modern Best Practices
With the clear advantages offered by let and const, the modern JavaScript paradigm has shifted.
const First
As a general rule, always start by declaring variables with const. This enforces a default of immutability for the variable binding, making your code more predictable and easier to reason about. It signals to other developers (and your future self) that this variable's reference should not change.
Then let
If you discover that a variable's value needs to be reassigned later in its scope (e.g., in a loop counter, a variable whose value is conditionally updated), then switch from const to let.
Rarely var
In new code, there's almost no compelling reason to use var. Its function-scoping and hoisting behaviors can be a source of bugs and make code harder to maintain. Its usage is primarily for maintaining compatibility with older codebases or specific, niche scenarios that are generally avoidable.
Conclusion
The introduction of let and const was a significant step forward for JavaScript, providing developers with more granular control over variable scope and immutability. By understanding their distinct behaviors compared to var, you can write cleaner, safer, and more robust JavaScript code. Embracing const as your default, and using let when reassignment is explicitly needed, are fundamental best practices for any modern JavaScript developer. This disciplined approach leads to fewer bugs and more maintainable applications.