JavaScript Scope

This article will attempt to deep dive in how scope works in JavaScript with practical examples and references to the official ECMAScript documentation.

Before we begin, there is a very important article you should read first detailing JavaScript Execution Context and how it works. This article will give you the perfect base to start understanding other important, and often misunderstood, topics in JavaScript.

Execution Context

Everytime a function is invoked, a new execution context is created and appended to the top of the callstack. Even if there is no function invocation, for example a for loop running in global scope, there is always at least the global execution context.

The execution context has two phases, the creation phase and the execution phase. In the creation phase the LexicalEnvironment and VariableEnvironment state components get created. Each execution context has a LexicalEnvironment and VariableEnvironment state component.

The LexicalEnvironment component is a pointer to an Environment Record. The Environment Record defines the association of identifiers to specific variables and functions based upon the lexical nesting structure of your code. The Environment Record also contains a property called [[OuterEnv]] which references it's outer LexicalEnvironment.

Execution Context and Scope

This environment record is what is commonly referred to as scope.

A for loop will not create a new execution context, it only modifies the environment record of the running execution context, and if the for loop is in global scope, then it modifies the environment record of the global execution context. Also, if you have some global code and a block is evaluated, such as an if statement with let or const declarations, then a new environment record is created, and the running execution context's lexical environment is pointed to the new environment record.

Scope is simply an Environment Record. Anytime a function is invoked, a new execution context is created and every execution context has a LexicalEnvironment component which is a pointer to an Environment Record.
JavaScript
function greet(name) {
  console.log(`hi ${name}`)
}

greet('david')

The below code represents how this might look, notice the topmost execution context is the function execution context, it is ordered just as it would be if it were in the callstack.

GreetExecutionContext = {
  LexicalEnvironment: GreetEnvironmentRecord
}

GreetEnvironmentRecord = {
  type: FunctionEnvironmentRecord,
  bindings: {
    name: 'david',
    arguments: {
      0: 'david',
      length: 1
    }
  },
  [[ThisValue]]: window, // for browsers
  [[ThisBindingStatus]]: "initialized",
  [[FunctionObject]]: <reference to greet>,
  [[NewTarget]]: undefined,
  [[OuterEnv]]: GlobalEnvironmentRecord 
}

GlobalExecutionContext = {
  LexicalEnvironment: GlobalEnvironmentRecord
}

GlobalEnvironmentRecord = {
  Type: GlobalEnvironmentRecord,

  [[ObjectRecord]]: {
    Type: ObjectEnvironmentRecord,
    [[BindingObject]]: <global object>, // e.g., window in browsers
    Bindings: {
      // Built-in globals (e.g., Math, Array, JSON, etc.)
      // Also properties created via `var` (not used in this example)
    },
    [[OuterEnv]]: null
  },

  [[DeclarativeRecord]]: {
    Type: DeclarativeEnvironmentRecord,
    Bindings: {
      foo: <ref. to greet function object>,
    },
    [[OuterEnv]]: null
  },

  [[GlobalThisValue]]: <global object>, // The value returned by this in global scope
  [[OuterEnv]]: null
}

How Scope Gets Updated

Here is a function, it's clear when this function is invoked, a new execution context will be created and appended to the top of the callstack.

JavaScript
function foo() {
  const x = 1

  if (x > 0) {
    const x = 2
    console.log(x) // 2
  }

  console.log(x) // 1
}

foo()

But how will the function's execution context handle the if block?

Let's first look at how the execution context is created for a function.

Functions in JavaScript are objects but special ones, they are special because they have an additional internal method, [[Call]]. For this reason you will often see functions in JavaScript referred to as callable objects. If we look at what the spec says about the [[Call]] internal method it says:

10.2.1 [[Call]] ( thisArgument, argumentsList )

  1. Let callerContext be the running execution context.
  2. Let calleeContext be PrepareForOrdinaryCall(F, undefined).
  3. Assert: calleeContext is now the running execution context.
  4. If F.[[IsClassConstructor]] is true, then
    1. Let error be a newly created TypeError object.
    2. NOTE: error is created in calleeContext with F's associated Realm Record.
    3. Remove calleeContext from the execution context stack and restore callerContext as the running execution context.
    4. Return ThrowCompletion(error).
  5. Perform OrdinaryCallBindThis(F, calleeContext, thisArgument).
  6. Let result be Completion(OrdinaryCallEvaluateBody(F, argumentsList)).
  7. Remove calleeContext from the execution context stack and restore callerContext as the running execution context.
  8. If result.[[Type]] is return, return result.[[Value]].
  9. ReturnIfAbrupt(result).
  10. Return undefined.

Now let's take a look at the abstract operation, PrepareForOrdinaryCall which returns a new execution context.

10.2.1.1 PrepareForOrdinaryCall ( F, newTarget )

The abstract operation PrepareForOrdinaryCall takes arguments F (a function object) and newTarget (an Object or undefined) and returns an execution context.

  1. Let callerContext be the running execution context.
  2. Let calleeContext be a new ECMAScript code execution context.
  3. Set the Function of calleeContext to F.
  4. Let calleeRealm be F.[[Realm]].
  5. Set the Realm of calleeContext to calleeRealm.
  6. Set the ScriptOrModule of calleeContext to F.[[ScriptOrModule]].
  7. Let localEnv be NewFunctionEnvironment(F, newTarget).
  8. Set the LexicalEnvironment of calleeContext to localEnv.
  9. Set the VariableEnvironment of calleeContext to localEnv.
  10. Set the PrivateEnvironment of calleeContext to F.[[PrivateEnvironment]].
  11. If callerContext is not already suspended, suspend callerContext.
  12. Push calleeContext onto the execution context stack; calleeContext is now the running execution context.
  13. NOTE: Any exception objects produced after this point are associated with calleeRealm.
  14. Return calleeContext.

When our example function is invoked, the above steps would take place. Now we need to figure out what happens in the example code block when a const declaration inside of a block is encountered. When line 5 is encountered, the Environment Record will be modified for the current execution context.

14.2.2 Runtime Semantics: Block Evaluation

  1. Let oldEnv be the running execution context's LexicalEnvironment.
  2. Let blockEnv be NewDeclarativeEnvironment(oldEnv).
  3. Perform BlockDeclarationInstantiation(StatementList, blockEnv).
  4. Set the running execution context's LexicalEnvironment to blockEnv.
  5. Let blockValue be Completion(Evaluation of StatementList).
  6. Set the running execution context's LexicalEnvironment to oldEnv.
  7. Return ? blockValue.

Pay attention to what is happening here, when a function is invoked and a block is evaluated, a new execution context is not created, rather a new envrionment record is created and the running execution context's lexical environment is pointed to the new environment record. On step 2, a new block scope is created. When the block is exited the running execution contexts lexical environment is pointed back to the previous environment record before the block was entered.

JavaScript
function foo() {
  const x = 1

  if (x > 0) {
    // step 2 occurs and a new environment record (scope) is created
    const x = 2
    console.log(x) // 2
  }

  console.log(x) // 1
}

foo()

Scope Chain

When an identifier such as a variable or function name is looked up, the engine first looks for it in the current scope’s environment record. If it’s not found there, it follows the [[OuterEnv]] link to the outer environment and continues this lookup through each enclosing environment until it either finds the binding or reaches the global scope. If the identifier cannot be resolved anywhere in this chain, a ReferenceError is thrown.

This chain of environment records linked by [[OuterEnv]] is called the scope chain, and the process of following it is known as scope lookup.

JavaScript
const name = 'david'

function greet() {
  console.log(`hi ${name}`)
}

greet()

When this code is executed, the JavaScript engine checks the current environment record for a binding for the identifier name. If it doesn’t find one, it follows the [[OuterEnv]] link to the next outer environment record and continues this process up the scope chain until it either finds the binding, or reaches the global scope. Here the binding is found in the global environment record.

GreetExecutionContext = {
  LexicalEnvironment: GreetEnvironmentRecord
}

GreetEnvironmentRecord = {
  type: FunctionEnvironmentRecord,
  bindings: {
    arguments: {
      length: 0
    }
  },
  [[ThisValue]]: window, // for browsers
  [[ThisBindingStatus]]: "initialized",
  [[FunctionObject]]: <reference to greet>,
  [[NewTarget]]: undefined,
  [[OuterEnv]]: GlobalEnvironmentRecord 
}


GlobalExecutionContext = {
  LexicalEnvironment: GlobalEnvironmentRecord
}

GlobalEnvironmentRecord = {
  Type: GlobalEnvironmentRecord,
  [[ObjectRecord]]: {
    Type: ObjectEnvironmentRecord,
    [[BindingObject]]: <global object>, // e.g., window in browsers
    Bindings: {
      // Built-in globals (e.g., Math, Array, JSON, etc.)
      // Also properties created via `var` (not used in this example)
    },
    [[OuterEnv]]: null
  },
  [[DeclarativeRecord]]: {
    Type: DeclarativeEnvironmentRecord,
    Bindings: {
      foo: <ref. to greet function object>,
      name: 'david'
    },
    [[OuterEnv]]: null
  },
  [[GlobalThisValue]]: <global object>,
  [[OuterEnv]]: null
}

Lexical Scope

Lexical scope refers to how scopes are determined during the lexing phase of compilation based on the physical structure of the code.

When the engine parses this code, the lexical structure tells it that foo will create a function environment record when executed, and baz will also create its own function environment record when executed. Notice the environment records will not be instantiated until the execution phase, at this point the parser has setup a blueprint for how the program will execute based on code structure. The environment record for baz will have its [[OuterEnv]] set to the environment record of foo at runtime, reflecting the lexical nesting of the code.

This lexical structure tells the engine how each environment record’s [[OuterEnv]] should be linked at runtime, forming the scope chain used to resolve identifiers.

JavaScript
function foo() {
  const bar = 'bar'

  function baz() {
    console.log(bar)
  }
}

Types of Scope

There are four types of scope:

  • global scope
  • function scope or local scope
  • module scope
  • block scope

The spec details Environment Record Operations, each one of these operations creates a new Environment Record.

  • NewDeclarativeEnvironment - block scope
  • NewObjectEnvironment (used when NewGlobalEnvironment is invoked since the Global Environment Record consists of an Object Environment Record and a Declarative Environment Record).
  • NewFunctionEnvironment - function or local scope
  • NewGlobalEnvironment - global scope
  • NewModuleEnvironment - module scope

Note that NewFunctionEnvironment creates a new Function Environment Record and NewModuleEnvironment creates a new Module Environment Record. Function Environment Records and Module Environment Records are subclasses of Declarative Environment Record.

One other thing to note, it isn't often used but the with keyword adds an Object Environment Record for a computed object to the Lexical Environment of the running execution context. It then executes a statement using the augmented Lexical Environment and then restores the original Lexical Environment.

Function Scope (local scope)

Functions create a scope for variables declared with var, let and const. When a function is invoked, a new function scope or more specifically, a new Function Environment Record is created with the NewFunctionEnvironment environment record operation.

JavaScript
function foo() {
  const x = 1
}

console.log(x)  // ReferenceError: x is not defined

Module Scope

Module scope is basically like a function and that's what modules essentially are. They are JavaScript files that have type="module" and the file itself acts as one big function.

Anytime <script type="module"> is evaluated, a new Module Environment Record is created by the NewModuleEnvironment environment record operation.

16.2.1.6.4 InitializeEnvironment ()

  1. Let env be NewModuleEnvironment(realm.[[GlobalEnv]]).

This line details at what point the module environment record should be created.

index.html
<script src="app1.js" type="module"></script>
<script src="app2.js" type="module"></script>

Global Scope

Anytime code is evaluated, there is always at least the global execution context. You can add a debugger statement inside any function and you will see (anonymous) inside of the call stack pane in chrome dev tools, this is simply the global execution context.

To expand on this a bit more, according to the spec:

Before it is evaluated, all ECMAScript code must be associated with a realm. Conceptually, a realm consists of a set of intrinsic objects, an ECMAScript global environment, all of the ECMAScript code that is loaded within the scope of that global environment, and other associated state and resources.

The abstract operation CreateRealm creates a new Realm Record and a Realm Record has a few fields, but we will only look at [[GlobalObject]] and [[GlobalEnv]]. The [[GlobalObject]] is an object or undefined and the [[GlobalEnv]] is a Global Environment Record or global scope. The [[OuterEnv]] of the Global Environment Record is null.

You can imagine the realm as a global execution context, since any time code is evaluated there must always be at least a global execution context.

The Global Environment Record or global scope is the default scope if none of the other rules apply. Here you can find the global object, which is window in the case of browsers, bindings for built in globals, properties of the global object ect. It is a single enclosing environment record that wraps an Object Environment Record and a Declarative Environment Record. The bindings for all the declarations in global code are contained in the Declarative Environment Record component of the Global Environment Record and bindings for all built-in globals as well as the binding object are contained in the Object Environment Record component of the Global Environment Record.

When this code is run, when you take a look at the call stack you will see (anonymous) and on top of that you will see foo. (anonymous) is the global execution context.

JavaScript
function foo() {
  debugger
}

foo()

We mentioned already that loops do not create a new execution context, they only modify the running execution context's Environment Record. When this code is run, you will only see (anonymous) in the call stack.

JavaScript
for (let i = 0; i < 3; i++) {
  debugger
}

// same goes for for in
for (const num of [1, 2, 3]) {
  debugger
}

In the below code block, const x = 1 is declared in global scope and is available in all the other files.

index.html
<script src="script.js"></script>
<script src="app1.js"></script>
<script src="app2.js"></script>

Block Scope

A new block scope is created for let x = 3. In the new environment record, x is in an uninitialized state. This binding cannot be accessed until the point where the let x = 3 declaration is executed and the binding is initialized. This period, during which the binding exists but is not yet initialized, is known as the Temporal Dead Zone (TDZ).

JavaScript
let x = 1
{
  x = 2 // this is trying to re-assign let x = 3 not let x = 1
  let x = 3
}
// Uncaught ReferenceError: Cannot access 'x' before initialization

The reason this happens is because in the execution phase, x is hoisted to the top of its scope.

JavaScript
let x = 1
{
  // x: uninitialized (hoisted to the top of the scope/environment record)
  x = 2
  // the only way to initialize let or const is with assignment and declaration statement
  // ie not x = 2, rather let x = 2. Same with `const`
  let x = 3
}
// Uncaught ReferenceError: Cannot access 'x' before initialization
ReferenceError occurs when you try to access a variable that doesn't exist.
JavaScript
console.log(x)

let x = 1
// Uncaught ReferenceError: x is not defined

Curly braces {} do not always create scopes, as is the case for function declarations and object literals. When a block is evaluated a new Declarative Environment Record (block scope) is created for let and const declarations, and bindings for each block scoped variable declared in the block are instantiated in the environment record.

JavaScript
{
  // here there is not necessarliy a scope yet
  
  var x = 1
  // still no block scope as a block scope is not created for var variables


  /* now the block needs to become a scope, by performing step 2
    Let blockEnv be NewDeclarativeEnvironment(oldEnv)
  */
  const x = 1
}

In other words, a block only becomes a scope if it has to in order to contain it's block scoped declarations such as let or const.

How Block Scope Is Evaluated According to the Spec

14.2.2 Runtime Semantics: Evaluation

  1. Let oldEnv be the running execution context's LexicalEnvironment.
  2. Let blockEnv be NewDeclarativeEnvironment(oldEnv).
  3. Perform BlockDeclarationInstantiation(StatementList, blockEnv).
  4. Set the running execution context's LexicalEnvironment to blockEnv.
  5. Let blockValue be Completion(Evaluation of StatementList).
  6. Set the running execution context's LexicalEnvironment to oldEnv.
  7. Return ? blockValue.

Here is a short visualization of how that might look given the following code block.

JavaScript
function foo() {
  const x = 1

  if (x > 0) {
    const x = 2
  }

  console.log(x)
}

foo()

Once line 1 is evaluated, the foo function object will be invoked and the state of the program will look like this:

FooExecutionContext = {
  LexicalEnvironment: FooEnvironmentRecord
}

FooEnvironmentRecord = {
  type: FunctionEnvironmentRecord,
  Bindings: {
    x: 1
  },
  [[ThisValue]]: window, // for browsers
  [[ThisBindingStatus]]: "initialized",
  [[FunctionObject]]: <reference to foo>,
  [[NewTarget]]: undefined,
  [[OuterEnv]]: GlobalEnvironmentRecord 
}

GlobalExecutionContext = {
  LexicalEnvironment: GlobalEnvironmentRecord
}

GlobalEnvironmentRecord = {
  Type: GlobalEnvironmentRecord,

  [[ObjectRecord]]: {
    Type: ObjectEnvironmentRecord,
    [[BindingObject]]: <global object>, // e.g., window in browsers
    Bindings: {
      // Built-in globals (e.g., Math, Array, JSON, etc.)
      // Also properties created via `var` (not used in this example)
    },
    [[OuterEnv]]: null
  },

  [[DeclarativeRecord]]: {
    Type: DeclarativeEnvironmentRecord,
    Bindings: {
      foo: <function foo> // This is where `foo` belongs
    },
    [[OuterEnv]]: null
  },

  [[GlobalThisValue]]: <global object>, // The value returned by this in global scope
  [[OuterEnv]]: null
}

Now line 5 is evaluated and a new Declarative Environment Record is created. The lexical environment for the execution context of foo is updated.

FooExecutionContext = {
  LexicalEnvironment: NewFooEnvironmentRecord
}

NewFooEnvironmentRecord = {
  type: DeclarativeEnvironmentRecord,
  Bindings: {
    x: 2
  },
  [[OuterEnv]]: FooEnvironmentRecord
}

FooEnvironmentRecord = {
  type: FunctionEnvironmentRecord,
  Bindings: {
    x: 1
  },
  [[ThisValue]]: window, // for browsers
  [[ThisBindingStatus]]: "initialized",
  [[FunctionObject]]: <reference to foo>,
  [[NewTarget]]: undefined,
  [[OuterEnv]]: GlobalEnvironmentRecord 
}

GlobalExecutionContext = {
  LexicalEnvironment: GlobalEnvironmentRecord
}

GlobalEnvironmentRecord = {
  Type: GlobalEnvironmentRecord,

  [[ObjectRecord]]: {
    Type: ObjectEnvironmentRecord,
    [[BindingObject]]: <global object>, // e.g., window in browsers
    Bindings: {
      // Built-in globals (e.g., Math, Array, JSON, etc.)
      // Also properties created via `var` (not used in this example)
    },
    [[OuterEnv]]: null
  },

  [[DeclarativeRecord]]: {
    Type: DeclarativeEnvironmentRecord,
    Bindings: {
      foo: <function foo> // This is where `foo` belongs
    },
    [[OuterEnv]]: null
  },

  [[GlobalThisValue]]: <global object>, // The value returned by this in global scope
  [[OuterEnv]]: null
}

When evaluation leaves the block then the Lexical Environment before the block was entered is restored.

FooExecutionContext = {
  LexicalEnvironment: FooEnvironmentRecord
}

FooEnvironmentRecord = {
  type: FunctionEnvironmentRecord,
  Bindings: {
    x: 1
  },
  [[ThisValue]]: window, // for browsers
  [[ThisBindingStatus]]: "initialized",
  [[FunctionObject]]: <reference to foo>,
  [[NewTarget]]: undefined,
  [[OuterEnv]]: GlobalEnvironmentRecord 
}

GlobalExecutionContext = {
  LexicalEnvironment: GlobalEnvironmentRecord
}

GlobalEnvironmentRecord = {
  Type: GlobalEnvironmentRecord,

  [[ObjectRecord]]: {
    Type: ObjectEnvironmentRecord,
    [[BindingObject]]: <global object>, // e.g., window in browsers
    Bindings: {
      // Built-in globals (e.g., Math, Array, JSON, etc.)
      // Also properties created via `var` (not used in this example)
    },
    [[OuterEnv]]: null
  },

  [[DeclarativeRecord]]: {
    Type: DeclarativeEnvironmentRecord,
    Bindings: {
      foo: <function foo> // This is where `foo` belongs
    },
    [[OuterEnv]]: null
  },

  [[GlobalThisValue]]: <global object>, // The value returned by this in global scope
  [[OuterEnv]]: null
}

var is Function Scoped but not Block Scoped

A new block scope is not created when a var binding is evaluated inside curly braces {}.

JavaScript
function foo() {
  var x = 1

  if (x > 0) {
    // no need to create a block scope
    var x = 2
  }

  console.log(x) // 2 not block scoped
}

console.log(x) // Uncaught ReferenceError: x is not defined
var can be redeclared
JavaScript
var x = 1

{
  var x = 2
}

// this logs 2 because var x = 2 is in the same scope as var x = 1
// we just wrote 
// var x = 1
// var x = 2
// console.log(x)
console.log(x) // 2
let and const can not be redeclared
JavaScript
// here we re-assign a value
let x = 1
x = 2

// here we try to re-declare a value
let x = 1
let x = 2 // Uncaught SyntaxError: Identifier 'x' has already been declared

Notice below we can re-declare x again since we are using var.

JavaScript
// here var is used, and is re declared, no error is thrown
var x = 1
var x = 2 // x = 2

The reason this works is when the block is evaluated a new environment record is created and the running execution context's lexical environment is pointed to this new environment record (blockEnv) and on line 7 of the below code block, the lexical environment is pointed back to oldEnv.

JavaScript
const x = 1

{
  const x = 2 // here blockEnv is created
}
// this is in global scope and the lexical environment
// of the global execution context is pointed back to oldEnv
console.log(x) // 1

Shadowing

We already talked about what scope lookup is, the compiler will look for a variable first in it's current scope and if it can't find it, it checks the [[OuterEnv]] field of the running execution context's environment record, stopping as soon as a match is found. Below the x parameter in the second code block shadows the global variable let x = 1.

JavaScript
let x = 1

function foo() {
  x++
  console.log(x) // 2
}

foo()
console.log(x) // 2

Note how the global variable let x = 1 is incremented. Here in the below code block, the local x parameter shadows the global let x = 1.

JavaScript
let x = 1

function foo(x) {
  // imagine declaring a variable here
  // let x = 1
  x++
  console.log(x) // 2
}

foo(1)

console.log(x) // 1

Here is another case of shadowing. From the inner forEach there is no way to console.log the outer num paramter.

JavaScript
const nums = [[1, 2], [3, 4], [5, 6]]

nums.forEach(num => {
  console.log(num) // [1, 2]
  num.forEach(num => {
    // here there is no way to access the outer num
    // because it's been shadowed
    // this is a new scope and a new num parameter 
    console.log(num) // 1 2
  })
})

If we needed access to the outer num parameter from the inner forEach, then we need to name the parameters differently.

JavaScript
nums.forEach(num => {
  num.forEach(i => {
    // here we have access to both
    // `num` and `i`
  })
})

Scope with Loops

Everthing we learned up to now still applies with loops. let and const are block scoped, and var is not. For each iteration a new environment record is created.

For each iteration of the below loop, a new block scope is not created because there is no let or const declaration. The JavaScript engine is lazy in this sense, as it only instantiates a scope if it has to, and since there's only a var declaration, and var is not block scoped, no block scope is created.

JavaScript
for (var i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(`val: ${i}`)
  }, 1000)
}

// val: 3
// val: 3
// val: 3

This can be fixed by just using let.

JavaScript
for (let i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(`val: ${i}`)
  }, 1000)
}

// 0, 1, 2

The reason we can't use const is because const cannot be reassigned.

JavaScript
for (const i = 0; i < 3; i++) {
  console.log(i)
}

// Uncaught TypeError: Assignment to constant variable.

Let's re-write this to better visualize what is happening.

JavaScript
{
  const i = 0
  i++
}

// Uncaught TypeError: Assignment to constant variable.

In the case of forEach, we pass a callback to it so for each iteration, a new execution context is created since a function is being called for each item in the array.

JavaScript
[1, 2, 3].forEach(num => {
  const foo = num
})

For Loop Scope Advanced

let declarations are special cased by for loops. If the initialization block is a let declaration, then everytime after the loop body is evaluated the following occurs:

  1. A new lexical scope is created with new let declared variables
  2. The binding values from the last iteration are used to re-initialize the new variables
  3. afterthought is evaluated in the new scope

What that means is at first a scope is created for the initialization block, and then the values are copied over to the new scope.

14.7.4.2 Runtime Semantics: ForLoopEvaluation

  1. Let oldEnv be the running execution context's LexicalEnvironment.
  2. Let loopEnv be NewDeclarativeEnvironment(oldEnv).
  3. Let isConst be IsConstantDeclaration of LexicalDeclaration.
  4. Let boundNames be the BoundNames of LexicalDeclaration.
  5. For each element dn of boundNames, do
    1. If isConst is true, then
      1. Perform ! loopEnv.CreateImmutableBinding(dn, true).
    2. Else,
      1. Perform ! loopEnv.CreateMutableBinding(dn, false).
  6. Set the running execution context's LexicalEnvironment to loopEnv.
  7. Let forDcl be Completion(Evaluation of LexicalDeclaration).
  8. If forDcl is an abrupt completion, then
    1. Set the running execution context's LexicalEnvironment to oldEnv.
    2. Return ? forDcl.
  9. If isConst is false, let perIterationLets be boundNames; otherwise let perIterationLets be a new empty List.
  10. If the first Expression is present, let test be the first Expression; otherwise, let test be empty.
  11. If the second Expression is present, let increment be the second Expression; otherwise, let increment be empty.
  12. Let bodyResult be Completion(ForBodyEvaluation(test, increment, Statement, perIterationLets, labelSet)).
  13. Set the running execution context's LexicalEnvironment to oldEnv.
  14. Return ? bodyResult.

Here notice step 2, the CreatePerIterationEnvironment operation runs, this is the first iteration of the loop. Here the variables are copied over from loopEnv which was setup in ForLoopEvaluation step 2. In this first iteration, the increment expression does not occur, it starts in the second iteration.

14.7.4.3 ForBodyEvaluation ( test, increment, stmt, perIterationBindings, labelSet )

  1. Let V be undefined.
  2. Perform ? CreatePerIterationEnvironment(perIterationBindings).
  3. Repeat,
    1. If test is not empty, then
      1. Let testRef be ? Evaluation of test.
      2. Let testValue be ? GetValue(testRef).
      3. If ToBoolean(testValue) is false, return V.
    2. Let result be Completion(Evaluation of stmt).
    3. If LoopContinues(result, labelSet) is false, return ? UpdateEmpty(result, V).
    4. If result.[[Value]] is not empty, set V to result.[[Value]].
    5. Perform ? CreatePerIterationEnvironment(perIterationBindings).
    6. If increment is not empty, then
      1. Let incRef be ? Evaluation of increment.
      2. Perform ? GetValue(incRef).

Below on step 1 a new environment record, thisIterationEnv, is created and the lexical environment of the current execution context is pointed at it.

Step 1 shows how the variables from the last iteration, lastIterationEnv, are copied over to thisIterationEnv, the new Environment Record from step 1.d.

14.7.4.4 CreatePerIterationEnvironment ( perIterationBindings )

  1. If perIterationBindings has any elements, then
    1. Let lastIterationEnv be the running execution context's LexicalEnvironment.
    2. Let outer be lastIterationEnv.[[OuterEnv]].
    3. Assert: outer is not null.
    4. Let thisIterationEnv be NewDeclarativeEnvironment(outer).
    5. For each element bn of perIterationBindings, do
      1. Perform ! thisIterationEnv.CreateMutableBinding(bn, false).
      2. Let lastValue be ? lastIterationEnv.GetBindingValue(bn, true).
      3. Perform ! thisIterationEnv.InitializeBinding(bn, lastValue).
    6. Set the running execution context's LexicalEnvironment to thisIterationEnv.
  2. Return unused.
JavaScript
for (let i = 0; i < 3; i++) {
  console.log(i)
}

// first evaluation of the loop setup in ForLoopEvaluation step 2
// loopEnv
{ 
  let i
  i = 0;
  __i = { i }
}

// CreatePerIterationEnvironment(perIterationBindings)
// thisIterationEnv
// first iteration no increment expression
{ 
  let { i } = __i
  if (i < 3) console.log(i) // 0
  __i = { i }
}

// second iteration increment begins
{ 
  let { i } = __i
  i++
  if (i < 3) console.log(i) // 1
  __i = { i }
}   
{ 
  let { i } = __i
  i++
  if (i < 3) console.log(i) // 2
  __i = { i }
} 
{ 
  let { i } = __i // 2
  i++ // 3
  // this will not run but a scope was created for it
  if (i < 3) console.log(i)
}

We can prove this behavior with the below code block. The reason 0 is logged 3 times is because getI closes over the i variable, which refers to the variable declared when the loop was first initialized. Subsequent updates to the value of i actually create new variables called i, which getI does not see.

JavaScript
for (
  let i = 0, getI = () => i, incrementI = () => i++;
  getI() < 3;
  incrementI()
) {
  console.log(i);
}

// 0, 0, 0

Parameter Scope

Default parameter initializers live in their own scope, which is a parent of the scope created for the function body.

This means that earlier parameters can be referred to in the initializers of later parameters. However, functions and variables declared in the function body cannot be referred to from default value parameter initializers; attempting to do so throws a run-time ReferenceError.

JavaScript
function sum(/* scope for default params */ a = 1, b = () => a) {
  // scope for function body
  return a + b()
}

sum() // 2

The parent scope created for default parameters is visible to the child scope created for the function body.

JavaScript
function sum(a = 1, b = () => a) {
  console.log(a) // 1
  console.log(b) // () => a
  return a + b()
}

sum() // 2

However, since the default parameters live in a parent scope of the function body, they can not reach into the child scope.

JavaScript
function sum(a = 1, b = () => console.log(c)) {
  const c = 2
  b()
}

sum()
// Uncaught ReferenceError: c is not defined

Parameters defined earlier (to the left) are available to later default parameters:

JavaScript
function sum(a = 1, b = a) {
  return a + b
}
sum() // 2

This will also work

JavaScript
function sum(b = () => a, a = 1) {
  return a + b() // by this time both parameters have been intialized
}
sum() // 2

There is one interesting edge case detailed in the spec under FunctionDeclarationInstantiation on line 28.e.6.

FunctionDeclarationInstantiation

A var with the same name as a formal parameter initially has the same value as the corresponding initialized parameter.

What this means is that if you shadow a parameter with var, it will be initialized to that value rather than the typical undefined value we expect with hoisting.

JavaScript
function sum(a = 1, b = () => a) {
  var a // this will not be auto initialized to undefined
  console.log(a) // 1 not undefined
}

sum()

Review

JavaScript scope is not hard to understand if you know how the JavaScript engine executes code under the hood. Each time a function is invoked, an if block is encountered, or a for loop runs in global scope, a new environment record will be created and that new environment record is what is commonly referred to as scope.

Scope chain is about how environment records reference outer environment records via their [[OuterEnv]] property.

  • There are four scopes: global, module, function (local), and block.
  • The spec details certain operations such as NewDeclarativeEnvironment, NewFunctionEnvironment ect. These operations create environment records.
  • Every time code is executed in JavaScript there is always at least the global execution context.
  • Every execution context has a lexical environment component.
  • A lexical environment is a pointer to an environment record.
  • When a for loop is executed, a new execution context is not created. Rather a new environment record is created and the running execution context's lexical environment is pointed to the new environment record.