Hoisting in JS: var,let and const explained
What You Need to Know About var, let, and const Hoisting in JavaScript
Hoisting is a concept that many JavaScript beginners find confusing or hard to grasp. It refers to the behaviour of JavaScript that moves all the declarations of variables and functions to the top of their scope before the code execution. This means that you can use a variable or a function before you declare or define it in your code. However, hoisting can also lead to some unexpected errors or bugs if you are not careful with it. In this blog, I will explain how hoisting works in JavaScript, what are the differences between var, let, and const keywords, and how to avoid common pitfalls related to hoisting. By the end of this blog, you will have a better understanding of hoisting and how to use it effectively in your JavaScript code.
When Can I Use a Variable?
At what point does a variable become available to use within its scope? There may seem to be an obvious answer: after the variable has been declared/created. Right? Not quite.
Consider:
greeting();
// Hello!
function greeting() {
console.log("Hello!");
}
This code works fine. You may have seen or even written code like it before. But did you ever wonder how or why it works?
Specifically, why can you access the identifier greeting from line 1 even though the greeting()
function declaration doesn’t occur until line 3?
The term most commonly used for a variable being visible from the beginning of its enclosing scope, even though its declaration may appear further down in the scope, is called hoisting. Whenever we enter in any scope then at that time every identifier is created at the beginning of the scope it belongs to.
But hoisting alone doesn’t fully answer the question. We can see an identifier called greeting from the beginning of the scope, but why can we call the greeting()
function before it’s been declared? The answer is a special characteristic of formal function declarations, called function hoisting.
When a function declaration’s name identifier is registered at the top of its scope, it’s additionally auto-initialized to that function’s reference. That’s why the function can be called throughout the entire scope!
One key detail is that both function hoisting and var-variable hoisting attach their name identifiers to the nearest enclosing function scope (or, if none, the global scope), not a block scope.
Declarations with let and const still hoist But these two declaration forms attach to their enclosing block rather than just an enclosing function as with var and function declarations.
Hoisting: Declaration vs. Expression
Function hoisting only applies to formal function declarations not to function expression assignments. Consider:
greeting();
// TypeError
var greeting = function greeting() {
console.log("Hello!");
};
Here greeting();
throws an error. But the kind of error
thrown is very important to notice. A TypeError means we’re trying to do something with a value that is not allowed.
Notice that the error is not a ReferenceError. JS isn’t telling us that it couldn’t find greeting as an identifier in the scope. It’s telling us that greeting
was found but doesn’t hold a function reference at that moment. Only functions can be invoked, so attempting to invoke some non-function value results in an error.
But what does greeting hold, if not the function reference? In addition to being hoisted, variables declared with var are also automatically initialized to undefined at the beginning of their scope—again, the nearest enclosing function, or the global. Once initialized, they’re available to be used (assigned to, retrieved from, etc.) throughout the whole scope.
So in that first line, greeting
exists, but it holds only the default undefined value. It’s not until line 3 that greeting
gets assigned the function reference. Pay close attention to the distinction here. A function declaration is hoisted and initialized to its function value (again, called function hoisting). A var variable is also hoisted and then auto-initialized to undefined. Any subsequent function expression assignments to that variable don’t happen
until that assignment is processed during runtime execution. In both cases, the name of the identifier is hoisted. However, the function reference association isn’t handled at initialization time (beginning of the scope) unless the identifier was created in a formal function declaration.
Variable Hoisting
Let’s look at another example of variable hoisting:
greeting = "Hello!";
console.log(greeting);
// Hello!
var greeting = "Howdy!";
Though greeting
isn’t declared until line 5, it’s available to be assigned as early as line 1. Why?
There are two necessary parts to the explanation:
the identifier is hoisted,
and it’s automatically initialized to the value undefined from the top of the scope.
You can think that your JS engine will rearrange the above code like this :
var greeting; // hoisted declaration
greeting = "Hello!"; // the original line 1
console.log(greeting); // Hello!
greeting = "Howdy!"; // `var` is gone!
Hoisting as a mechanism for re-ordering code may be an
attractive simplification, but it’s not accurate. The JS engine doesn’t re-arrange the code. It can’t magically look ahead and find declarations; the only way to accurately find them, as well as all the scope boundaries in the program, would be to fully parse the code.
For function declaration hosting we can see below example:
studentName = "Suzy";
greeting();
// Hello Suzy!
function greeting() {
console.log(`Hello ${ studentName }!`);
}
var studentName;
The above program when rearranged by the JS engine will look like this :
function greeting() {
console.log(`Hello ${ studentName }!`);
}
var studentName;
studentName = "Suzy";
greeting();
// Hello Suzy!
Hoisting more accurately said is the compile time operation of generating runtime instructions for the automatic registration of a variable at the beginning of its scope, each time that scope is entered.
Re-declaration?
What do you think happens when a variable is declared more
than once in the same scope? Consider:
var studentName = "Frank";
console.log(studentName);
// Frank
var studentName;
console.log(studentName); // ???
What do you expect to be printed for that second message?
You may believe the second var studentName
has re-declared the variable (and thus “reset” it), so they expect undefined to be printed. But is there such a thing as a variable being “re-declared” in the same scope? No
Let's try to think above example from a hoisting perspective that your JS engine will rearrange the above code something like this:
var studentName;
var studentName; // clearly a pointless operation
studentName = "Frank";
console.log(studentName);
// Frank
console.log(studentName);
// Frank
When the compiler finds the second var declaration statement and then it will check the current scope if it has already seen a studentName
identifier; since it had, there wouldn’t be anything else to do. It’s also important to point out that var studentName
; doesn’t mean var studentName = undefined;
as most assume. Let’s prove they’re different by considering this variation of the program:
var studentName = "Frank";
console.log(studentName); // Frank
var studentName;
console.log(studentName); // Frank <--- still!
// let's add the initialization explicitly
var studentName = undefined;
console.log(studentName); // undefined <--- see!?
This code says that var studentName;
does not mean var studentName = undefined;
. They are different statements. The first one declares the variable without assigning any value to it. The second one declares the variable and assigns the value undefined
to it so a repeated var declaration of the same identifier name in a scope is effectively a do-nothing operation.
Let now see this same with function declaration
var greeting;
function greeting() {
console.log("Hello!");
}
// basically, a no-op
var greeting;
typeof greeting; // "function"
var greeting = "Hello!";
typeof greeting; // "string"
The first greeting
declaration registers the identifier to the scope, and because it’s a var the auto-initialization will be undefined. The function declaration doesn’t need to reregister the identifier, but because of function hoisting, it overrides the auto-initialization to use the function reference. The second var greeting by itself doesn’t do anything since greeting
is already an identifier and function hoisting already took precedence for the auto-initialization. Assigning "Hello!" to greeting
changes its value from the initial function greeting()
to the string; var itself doesn’t have any effect.
What about repeating a declaration within a scope using let or const?
let studentName = "Frank";
console.log(studentName);
let studentName = "Suzy";
This program will not execute, but instead immediately throw a SyntaxError. You will get an error like “studentName
has already been declared.” It’s not just that two declarations involving let will throw this error. If either declaration uses let, the other can be either let or var, and the error will still occur, as illustrated by these two variations:
var studentName = "Frank";
let studentName = "Suzy";
and:
let studentName = "Frank";
var studentName = "Suzy";
In both cases, a SyntaxError is thrown on the second declaration. In other words, the only way to “re-declare” a variable is to use var for all (two or more) of its declarations.
Constants?
The const keyword is more constrained than let. Like let, const cannot be repeated with the same identifier in the same scope. The const keyword requires a variable to be initialized, so omitting an assignment from the declaration results in a SyntaxError:
const empty; // SyntaxError
const declarations create variables that cannot be re-assigned:
const studentName = "Frank";
console.log(studentName);
// Frank
studentName = "Suzy"; // TypeError
The studentName variable cannot be re-assigned because it’s declared with a const. The error is thrown when re-assigning studentName
is a TypeError, not a SyntaxError. The subtle distinction here is pretty important, but unfortunately far too easy to miss. Syntax errors represent faults in the program that stop it from even starting execution. Type errors represent faults that arise during program execution. In the preceding snippet, "Frank"
is printed out before we process the re-assignment of studentName
, which then throws the error. const must disallow any “re-declarations”: any const “re-declaration” would also necessarily be a const re-assignment, which can’t be allowed!
const studentName = "Frank";
// obviously this must be an error
const studentName = "Suzy";
Loops
Consider:
var keepGoing = true;
while (keepGoing) {
let value = Math.random();
if (value > 0.5) {
keepGoing = false;
}
}
Is value
being “re-declared” repeatedly in this program? Will we get errors thrown? No. All the rules of scope (including “re-declaration” of let created variables) are applied per scope instance. In other words, each time a scope is entered during execution, everything resets. Each loop iteration is its own new scope instance, and within each scope instance, value
is only declared once. So there’s no attempted “re-declaration,” and thus no error. What if the value
declaration in the previous snippet was changed to a var?
var keepGoing = true;
while (keepGoing) {
var value = Math.random();
if (value > 0.5) {
keepGoing = false;
}
}
Is value being “re-declared” here, especially since we know var allows it? No. Because var is not treated as a block scoping declaration, it attaches itself to the global scope. So there’s just one value variable, in the same scope as keepGoing (global scope, in this case). No “redeclaration” here, either! One way to keep this all straight is to remember that var, let, and const keywords are effectively removed from the code by the time it starts to execute. They’re handled entirely by the compiler. What about “re-declaration” with other loop forms, like for-loops?
for (let i = 0; i < 3; i++) {
let value = i * 10;
console.log(`${ i }: ${ value }`);
}
// 0: 0
// 1: 10
// 2: 20
It should be clear that there’s only one value declared per scope instance. But what about i
? Is it being “re-declared”? To answer that, consider what scope i
is in. It might seem like it would be in the outer (in this case, global) scope, but it’s not. It’s in the scope of a for-loop body, just like value is. In fact, you could sorta think about that loop in this more verbose equivalent form:
{
// a fictional variable for illustration
let $$i = 0;
for ( /* nothing */; $$i < 3; $$i++) {
// here's our actual loop `i`!
let i = $$i;
let value = i * 10;
console.log(`${ i }: ${ value }`);
}
// 0: 0
// 1: 10
// 2: 20
}
Now it should be clear: the i
and value variables are both declared exactly once per scope instance. No “re-declaration” here. What about other for-loop forms?
for (let index in students) {
// this is fine
}
for (let student of students) {
// so is this
}
Same thing with for..in and for..of loops: the declared variable is treated as inside the loop body, and thus is handled per iteration (aka, per scope instance). No “re-declaration. let’s explore how const impacts these looping constructs. Consider:
var keepGoing = true;
while (keepGoing) {
// ooo, a shiny constant!
const value = Math.random();
if (value > 0.5) {
keepGoing = false;
}
}
Just like the let variant of this program, we saw earlier, const is being run exactly once within each loop iteration, so it’s safe from “re-declaration” troubles. But things get more complicated when we talk about for-loops for..in and for..of are fine to use with const:
for (const index in students) {
// this is fine
}
for (const student of students) {
// this is also fine
}
But not the general for-loop:
for (const i = 0; i < 3; i++) {
// oops, this is going to fail with
// a Type Error after the first iteration
}
What’s wrong here? We could use let just fine in this construct, and we asserted that it creates a new i
for each loop iteration scope, so it doesn’t even seem to be a “redeclaration.” Let’s mentally “expand” that loop like we did earlier:
{
// a fictional variable for illustration
const $$i = 0;
for ( ; $$i < 3; $$i++) {
// here's our actual loop `i`!
const i = $$i;
// ..
}
}
Do you spot the problem? Our i
is indeed just created once inside the loop. That’s not the problem. The problem is the conceptual $$i
that must be incremented each time with the $$i++
expression. That’s re-assignment (not “redeclaration”), which isn’t allowed for constants. Remember, this “expanded” form is only a conceptual model to help you intuit the source of the problem.