Javascript Hoisting and Temporal Dead Zone (TDZ)
If you read the last two articles and made it this far, this article will be a breeze, we don't really have any new concepts to cover here but if you haven't already, it's important you first read these two articles to understand the concepts covered here as they lay the foundation.
When learning javascript, there are so many terms that get thrown around and most of the time the metaphors are confusing, and adding to confusion, the information is completely wrong.
One of these confusing concepts is called hoisting.
What is Hoisting
When you hear the word hoisting the first thing that comes to mind is lifting something up.
The wrong assumption is that the javascript compiler will re-arrange the code. This simply does not happen. If you see this example out in the wild it's important to understand it's wrong.
console.log(x)
var x = 1
// hoisting asserts that the code is re-arranged to this
var x
console.log(x) // undefined
x = 1
This is not at all what happens. Back when we talked about Execution Context, we discussed a javascript program is processed in two phases: compilation/parsing and execution.
In the compilation phase the scopes are determined and variables are registered to their respective scopes, this creates an AST or Abstract Syntax Tree but none of the scopes are actually created until runtime.
Once we move into the execution phase, the javascript engine uses devices called execution contexts which are used to track the runtime evaluation of code. The javascript engine will look at this map of scopes and registered variables, or AST, created in the compilation phase and start initializing all the variables. Hoisting is the compile time operation of creating instructions for use at runtime detailing how variables should be initialized each time the scope is evaluated.
We can use an example to make this a bit more clear.
const a = 'a'
let b = 'b'
var c = 'c'
function foo() {
console.log(a, b, c)
}
Now that we are in the execution/runtime phase, a global execution context will be created and pushed onto the top of the execution context stack or call stack. The first thing that happens before the engine starts to execute the code is the engine will come across the identifiers and initialize them. It will see in the AST created at compile time there is a global scope and the global scope has three variables: const a = 'a'
, let b = 'b'
, and var c = 'c'
as well as a function declaration: function foo() { console.log (a, b, c)}
. Each one of these has instructions attached from the compilation/parsing phase that let the engine know how they should be initialized.
var
will be auto initialized to undefined
, let
and const
will not be initialized, and function declarations will be auto initialized to their function reference.
The execution context will first hoist all the declarations. Think of hoisting as a runtime setup before code actually starts executing.
GlobalExecutionContext = {
LexicalEnvironment: {
a: uninitialized,
b: unintialized,
foo: <ref. to foo function object>,
[[OuterEnv]]: null
},
VariableEnvironment: {
c: undefined,
[[OuterEnv]]: null
}
}
Then the code will start to execute. When line 1 is reached, const a = 'a'
, the variable a
will be initialized to the value 'a'
.
Now the engine is at line 1 and initialized the variable const a = 'a'
. At this exact moment in time, this is how the state of execution for our javascript engine looks, notice on line 4 b
is still unintialized as our execution has not yet made it that far.
GlobalExecutionContext = {
LexicalEnvironment: {
a: 'a',
b: unintialized,
foo: <ref. to foo function object>,
[[OuterEnv]]: null
},
VariableEnvironment: {
c: undefined,
[[OuterEnv]]: null
}
}
Execution will continue, line 2
will be evaluated and the code block will look like this, notice b
is now initialized.
GlobalExecutionContext = {
LexicalEnvironment: {
a: 'a',
b: 'b',
foo: <ref. to foo function object>,
[[OuterEnv]]: null
},
VariableEnvironment: {
c: undefined,
[[OuterEnv]]: null
}
}
Notice above that var c
was already auto initialized to undefined
, this is what hoisting is. Once line 3
is evaluated, var c
will be re-assigned the value c
.
GlobalExecutionContext = {
LexicalEnvironment: {
a: 'a',
b: 'b',
foo: <ref. to foo function object>,
[[OuterEnv]]: null
},
VariableEnvironment: {
c: 'c',
[[OuterEnv]]: null
}
}
Temporal Dead Zone (TDZ)
The temporal dead zone is not a syntactic location in code, but rather the time between a variable being created and its initialization. The time below the two highlighted lines is referred to as the temporal dead zone.
const a = 1
const b = 2
// these are already mapped and in the AST from the js compilation phase
// now in the runtime phase the variables will be created
GlobalExecutionContext = {
LexicalEnvironment: {
a: unintialized
b: unintialized,
[[OuterEnv]]: null
}
}
// variable initialization is complete now execution begins
GlobalExecutionContext = {
LexicalEnvironment: {
a: 1,
b: uninitialized,
[[OuterEnv]]: null
}
}
Temporal Dead Zone Error
We can see a temporal dead zone error when we try to access a variable before it's initialized.
// x: unintialized
console.log(x)
// here x will become initialized to 1
let x = 1
// Uncaught ReferenceError: x is not defined
Function Expressions Hoist
// x: undefined
console.log(x) // undefined
// here x is re assigned 1
var x = 1
console.log(x) // 1
// note that function expressions are also
// hoisted and intialized to undefined
// foo: undefined
foo() // same as undefined()
var foo = () => console.log('foo')
// Uncaught TypeError: foo is not a function
Re-declaration in the same scope
let
and const
declarations cannot be re-declared in the same scope, var
can be re-declared.
let x = 1
let x = 2
// Uncaught SyntaxError: Identifier 'x' has already been declared
With var
re-declaration is allowed.
var x = 1
var x = 2
console.log(x) // 2
Let's take a look at another example. Below when the code was being compiled, var x = 1
was scanned and a var x
variable was registered to the identified scope, although it was not yet created. When the second var x
is encounted on line 3, the compiler had already registered a var
variable with identifier x
and does nothing with it.
var x = 1
console.log(x) // 1
var x // this does not reset the variable
console.log(x) // still 1 not undefined
Here is another interesting example. The engine during compilation registers the identifier x
from line 1 to the global scope, it then identifies a block scope and comes across the identifier x
from line 3 which is then registered for the block scope. Line 3 is trying to assign a value to a variable that is still uninitialized and let
and const
variables can only be initialized with a full declaration, ie let x = 2
.
let x = 1
{
x = 3 // Uncaught ReferenceError: Cannot access 'x' before initialization
let x = 2
}
Function Re-Declaration in the Same Scope
If functions are auto initialized to their function object, then what happens if we have two declarations for a function?
According to the spec in the section on GlobalDeclarationInstantiation
NOTE: If there are multiple function declarations for the same name, the last declaration is used.
function foo() {
console.log('foo')
}
function foo() {
console.log('bar')
}
foo() // bar
Example
Let's look at this code step by step to see how this would be executed taking hoisting into account. Copy and paste this code into the dev tools console to step through the code evaluation.
const a = 1
let b = 2
var c = 3
function foo() {
const a = 1
return a + b + c
}
foo()
foo()
During runtime, the engine will consult with the AST created in the compilation phase and start instantiating scopes and registering the respective variables for each scope, each time that scope is evaluated.
For the global execution context, in this order, the following steps occur:
- a global execution context will be created (
ScriptEvaluation
) - the LexicalEnvironment and VariableEnvironment component of the global execution context will be setup
- the global execution context will be pushed onto the execution context stack, global execution context is now the running execution context
- all of the global declarations will be instantiated (
GlobalDeclarationInstantiation
)- creates bindingings that are uninitialized for
let
(CreateMutableBinding
) andconst
(CreateImmutableBinding
) - If there are functions to initialize, instantiates the function object (
InstantiateFunctionObject
)InstantiateFunctionObject
adds all the methods the object will need to be a function[[Call]]
ect... and returns a function object.
- creates a mutable binding in the object environment record (which means it is accessible on the window) and initializes to its function object created in the previous step which was the function object returned from
InstantiateFunctionObject
. (CreateGlobalFunctionBinding
) - creates mutable bindings that are intialized to
undefined
forvar
in the object environment record (CreateGlobalVarBinding
). If it already exists, it's reused and assumed to be initialized.
- creates bindingings that are uninitialized for
- code evaluation will begin and when a declaration is evaluated, the binding will be initialized for
let
andconst
, forvar
the binding will be re-assigned to the respective value. - the code is evaluated and the running execution context is removed from the callstack
- the context that is now on top of the stack is resumed
For the function execution context, in this order, the following steps occur:
- During runtime if a set of parethesis is encountered
( )
the operationCall()
runs. This operation calls the internal[[Call]]
method of the function object. The[[Call]]
internal method was added to the function object inOrdinaryFunctionCreate
[[Call]]
is calledPrepareForOrdinaryCall
is called- a new execution context is created
- a new function environment record or function scope is created with the NewFunctionEnvironment operation
- the LexicalEnvironment and VariableEnvironment are pointed to the function environment record that was just created
- running execution context is suspended
- this execution context is pushed on`to the execution context stack and is now the running execution context
this
value is bound- evaluation of the function body's code begins (
OrdinaryCallEvaluateBody
)- calls
EvaluateBody
which callsEvaluateFunctionBody
EvaluateFunctionBody
has two steps: 1. callingFunctionDeclarationInstantiation
which creates the variables and hoists them and 2. Evaluation of the function, here is where thelet
andconst
variables will be initialized.
- calls
- this running execution context is removed from the call stack and previous execution context is restored as the running execution context.
- if there was a return value, then return it
- return the return value of
[[Call]]
Taking into consideration what we just learned this is how the javascript engine view the code block from above. A global execution context will be created and our program looks like this. Imagine the below code block as the execution context stack or callstack. New execution contexts are appended to the top.
GlobalExecutionContext = {
LexicalEnvironment: {
a: uninitialized,
b: unintialized,
foo: <ref. to foo function object>,
[[OuterEnv]]: null
},
VariableEnvironment: {
c: undefined,
[[OuterEnv]]: null
}
}
Everything is now setup, all the declarations are hoisted, including let
and const
, they are just unintialized
and evaluation begins. Once line 1 is evaluated the const a
variable is initialized to 1
.
GlobalExecutionContext = {
LexicalEnvironment: {
a: 1,
b: unintialized,
foo: <ref. to foo function object>,
[[OuterEnv]]: null
},
VariableEnvironment: {
c: undefined,
[[OuterEnv]]: null
}
}
line 2
is evaluated
GlobalExecutionContext = {
LexicalEnvironment: {
a: 1,
b: 2,
foo: <ref. to foo function object>,
[[OuterEnv]]: null
},
VariableEnvironment: {
c: undefined,
[[OuterEnv]]: null
}
}
line 3
is evaluated and var c
is re-assigned to 3
.
GlobalExecutionContext = {
LexicalEnvironment: {
a: 1,
b: 2,
foo: <ref. to foo function object>,
[[OuterEnv]]: null
},
VariableEnvironment: {
c: 3,
[[OuterEnv]]: null
}
}
line 10
is evaluated. There is nothing to do on line 5
because the function object was already created and registered in the environment record. On line 10
while the global execution context is evaluating code, the function reference foo
is found with ( )
so the Call()
method is called which calls the function objects internal [[Call]]
method. The variables are created in FunctionDeclarationInstantiation
and the execution context is ready to start evaluating the statements of the function.
FunctionExecutionContext = {
LexicalEnvironment: {
a: unintialized,
[[OuterEnv]]: <ref. to GlobalExecutionContext Environment Record>
}
}
GlobalExecutionContext = {
LexicalEnvironment: {
a: 1,
b: 2,
foo: <ref. to foo function object>,
[[OuterEnv]]: null
},
VariableEnvironment: {
c: 3,
[[OuterEnv]]: null
}
}
line 6
is evaluated
FunctionExecutionContext = {
LexicalEnvironment: {
a: 1,
[[OuterEnv]]: <ref. to GlobalExecutionContext Environment Record>
}
}
GlobalExecutionContext = {
LexicalEnvironment: {
a: 1,
b: 2,
foo: <ref. to foo function object>,
[[OuterEnv]]: null
},
VariableEnvironment: {
c: 3,
[[OuterEnv]]: null
}
}
line 7
is evaluated and a normal completion record is returned with the value 6
. [[Call]]
then removes the function execution context from the execution context stack, restores the global execution context as the running execution context, and returns the [[Value]]
of the normal completion record which is 6
. This result that is returned from [[Call]]
is returned from the Call()
operation.
The global execution context resumes execution and makes its way to line 12
and the process repeats.
GlobalExecutionContext = {
LexicalEnvironment: {
a: 1,
b: 2,
foo: <ref. to foo function object>,
[[OuterEnv]]: null
},
VariableEnvironment: {
c: 3,
[[OuterEnv]]: null
}
}
line 12
is evaluated, Call()
is called invoking the function objects internal [[Call]]
method, a new function execution context is created and pushed onto the top of the call stack and variables are created and hoisted.
FunctionExecutionContext = {
LexicalEnvironment: {
a: uninitialized,
[[OuterEnv]]: <ref. to GlobalExecutionContext Environment Record>
}
}
GlobalExecutionContext = {
LexicalEnvironment: {
a: 1,
b: 2,
foo: <ref. to foo function object>,
[[OuterEnv]]: null
},
VariableEnvironment: {
c: 3,
[[OuterEnv]]: null
}
}
line 6
is evaluated and initializes const a
to 1
.
FunctionExecutionContext = {
LexicalEnvironment: {
a: 1,
[[OuterEnv]]: <ref. to GlobalExecutionContext Environment Record>
}
}
GlobalExecutionContext = {
LexicalEnvironment: {
a: 1,
b: 2,
foo: <ref. to foo function object>,
[[OuterEnv]]: null
},
VariableEnvironment: {
c: 3,
[[OuterEnv]]: null
}
}
The rest of the steps are the same as above. When the function execution context is popped from the callstack and the global execution context resumes evaluation, once the global execution context finishes, it will also be popped and the callstack will be empty.
Notice how when foo()
is invoked a second time on line 12
, const a
is created again new, it is again set to uninitialized, and then it is initialized during evaluation when the engine reaches the declaration const a = 1
. This is why we said hoisting is the auto initialization of variables each time that scope is entered.
Review
Hoisting does not re-arrange any code, it simply describes how the js engine auto initializes variables during runtime. let
and const
do hoist to the top of their scopes and are set to uninitialized
until execution begins and the line where they are declared is evaluated, this time window is the temporal dead zone.