Chapter 17

Environment Records and Reference Records: The Complete Scope Chain

Scope is "the range of visibility of a variable," and JavaScript determines it through lexical scopingโ€”where the code is written is where the scope is fixed

JavaScript uses lexical scoping (also called static scoping): the rules for looking up a variable are determined by where the code is written, not by where the function is called. This is fundamentally different from dynamically scoped languages such as early Perl and Bash. Once JavaScript code is parsed, which scope each variable reference belongs to is already fixed.

The physical carrier of the scope chain is the Environment Record (ER). Each ER has an [[OuterEnv]] pointer to the outer ER, forming a chain. Variable lookup starts from the innermost ER and proceeds outward, layer by layer, until the binding is found or the Global ER's [[OuterEnv]] (which is null) is reached. If the chain is exhausted without finding the binding, a ReferenceError is thrown.

The Reference Record is an internal specification type used to represent "a reference that has not yet been resolved to a value." The expression foo.bar produces a Reference Record at the specification level, rather than directly producing the value of bar. Understanding Reference Records is the key to fully understanding delete, strict-mode assignment errors, and how this is resolved.


๐Ÿ”น Level 1 ยท What You Need to Know

Lexical Scoping: Determined by the Location of Writing

const x = 'global';

function outer() {
  const x = 'outer';

  function inner() {
    console.log(x); // 'outer', not 'global'
    // inner's scope chain: inner ER โ†’ outer ER โ†’ Global ER
    // x is found in outer ER
  }

  inner();
}

outer();

inner is defined inside outer, so its outer ER is outer's ER, not the ER at the call site of inner. This is the core of lexical scoping: scope is determined by code structure (lexical position), not by the call stack.

Variable Lookup: From Inside Out; ReferenceError If Not Found

function foo() {
  const a = 1;
  console.log(a); // found: a is in foo's ER
  console.log(b); // not found: not in foo's ER, not in Global ER โ†’ ReferenceError
}

foo();

During variable lookup, the engine starts from the current ER and moves outward layer by layer. If found, it returns the binding's value. If the entire chain is exhausted without a match, it throws ReferenceError: b is not defined. This is not undefinedโ€”undefined means "the variable exists but has no value"; ReferenceError means "the variable does not exist at all."

Scope Rules for the Three Declaration Forms

{
  var a = 1;   // hoisted to function/global scope
  let b = 2;   // visible only within this {} block
  const c = 3; // visible only within this {} block
}

console.log(a); // 1 โ† var pierces the {}
console.log(b); // ReferenceError โ† let is contained within the block
console.log(c); // ReferenceError โ† const, same

๐Ÿ”ธ Level 2 ยท How It Actually Works

The Complete Hierarchy of 5 Environment Record Types

The specification defines 5 ER types, arranged in a hierarchy:

Environment Record (abstract base)
โ”œโ”€ DeclarativeEnvironmentRecord
โ”‚   โ”œโ”€ FunctionEnvironmentRecord   (created per function call)
โ”‚   โ””โ”€ ModuleEnvironmentRecord     (top level of an ESM module)
โ”œโ”€ ObjectEnvironmentRecord          (created by the with statement)
โ””โ”€ GlobalEnvironmentRecord          (global scope; a composite)
    โ”œโ”€ [[ObjectRecord]]    โ†’ ObjectEnvironmentRecord
    โ””โ”€ [[DeclarativeRecord]] โ†’ DeclarativeEnvironmentRecord

DeclarativeEnvironmentRecord: The most common ER; stores bindings for let/const/class/function/import. Bindings live in an internal record, not on any object, and cannot be deleted.

ObjectEnvironmentRecord: Associated with an object; the object's properties are the ER's bindings. with (obj) { foo } creates an ObjectEnvironmentRecord using obj as the binding object. var declarations in global scope attach to the global object via the ObjectEnvironmentRecord inside the GlobalEnvironmentRecord.

FunctionEnvironmentRecord: Extends DeclarativeEnvironmentRecord with additional fields:

ModuleEnvironmentRecord: Extends DeclarativeEnvironmentRecord with support for import bindings (read-only live bindings whose [[OuterEnv]] points to the Global ER). A value exported from a module and imported elsewhere is a live bindingโ€”if the exporting module changes the value, the importing side immediately sees the new value.

GlobalEnvironmentRecord: The composite of the global scope. It does not directly extend any single ER type; instead it combines an ObjectEnvironmentRecord (managing var declarations and global object properties) and a DeclarativeEnvironmentRecord (managing let/const/class).

The Complete Variable Lookup Process Across the Scope Chain

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚        Variable Lookup Flow (GetIdentifierReference)               โ”‚
โ”‚                                                                    โ”‚
โ”‚  Current EC                                                        โ”‚
โ”‚  โ””โ”€ LexicalEnvironment (inner ER)                                  โ”‚
โ”‚      โ”œโ”€ Search inner ER for variable x                             โ”‚
โ”‚      โ”‚   โ”œโ”€ Found โ†’ return Reference Record                        โ”‚
โ”‚      โ”‚   โ”‚          { Base: inner ER, ReferencedName: x }          โ”‚
โ”‚      โ”‚   โ””โ”€ Not found โ†“                                            โ”‚
โ”‚      โ””โ”€ [[OuterEnv]] โ†’ outer ER                                    โ”‚
โ”‚          โ”œโ”€ Search outer ER for variable x                         โ”‚
โ”‚          โ”‚   โ”œโ”€ Found โ†’ return Reference Record                    โ”‚
โ”‚          โ”‚   โ””โ”€ Not found โ†“                                        โ”‚
โ”‚          โ””โ”€ [[OuterEnv]] โ†’ Global ER                               โ”‚
โ”‚              โ”œโ”€ First check [[DeclarativeRecord]]                  โ”‚
โ”‚              โ”‚   โ”œโ”€ Found โ†’ return Reference Record                โ”‚
โ”‚              โ”‚   โ””โ”€ Not found โ†’ check [[ObjectRecord]]             โ”‚
โ”‚              โ”‚       (global object properties)                    โ”‚
โ”‚              โ”‚       โ”œโ”€ Found โ†’ return Reference Record            โ”‚
โ”‚              โ”‚       โ””โ”€ Not found โ†“                               โ”‚
โ”‚              โ””โ”€ [[OuterEnv]] = null                                โ”‚
โ”‚                  โ†’ create Unresolvable Reference                   โ”‚
โ”‚                  โ†’ GetValue throws ReferenceError                  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Reference Record: A Specification-Level "Reference"

Reference Record is a specification type defined by ECMAScriptโ€”it is not a runtime value; it is an internal structure the specification uses to describe "the location of a binding."

A Reference Record contains:

Field Type Meaning
[[Base]] Environment Record / Object / unresolvable The environment or object holding the binding
[[ReferencedName]] String / Symbol The name of the binding
[[Strict]] Boolean Whether in strict mode
[[ThisValue]] Any / empty Used for super references

Example: The expression foo.bar produces a Reference Record: { Base: foo (object), ReferencedName: 'bar', Strict: false }

GetValue(ref): Retrieves the actual value from a Reference Record. When calling foo.bar(), the engine calls GetValue on foo.bar to get the actual function object, then invokes it (with foo as this, because [[Base]] is foo).

PutValue(ref, value): Writes a value into the location the Reference Record points to. The semantics of foo.bar = 1 are: call PutValue on the Reference Record produced by foo.bar, writing 1 into the bar property of foo.

The delete operator's semantics depend on the Reference Record:

Lookup Order in ObjectEnvironmentRecord Created by with

const x = 'global';
const obj = { x: 'obj', y: 'obj-y' };

with (obj) {
  console.log(x); // 'obj' โ† found in obj's ObjectER first
  console.log(y); // 'obj-y' โ† same
  console.log(z); // ReferenceError โ† obj has no z; outer chain also has none
}

with (obj) creates an ObjectEnvironmentRecord using obj as the binding object. This ER is inserted between the original scope chain and the Global ER. Why with is dangerous: if obj's properties change dynamically at runtime, lookup results change silently, making static analysis impossible and preventing V8's JIT compiler from optimizing variable lookups (the JIT must know variable locations at compile time to generate efficient machine code). In strict mode, with is completely forbidden.


๐Ÿ”บ Level 3 ยท What the Specification Says

Specification Section 9.1: Environment Records

Section 9.1 defines Environment Record as an abstract base type. All ER types share 6 base methods (plus ER-specific ones):

Method Meaning
HasBinding(N) Whether a binding named N exists
CreateMutableBinding(N, D) Create a mutable binding (D=true means deletable)
CreateImmutableBinding(N, S) Create an immutable binding (S=true means strict initialization check)
InitializeBinding(N, V) Initialize the binding (TDZ โ†’ initialized)
SetMutableBinding(N, V, S) Set the value of a mutable binding (S=true throws on write to read-only)
GetBindingValue(N, S) Get the binding's value (throws ReferenceError if in TDZ)
DeleteBinding(N) Delete the binding (returns false for non-deletable bindings in DeclarativeER)
HasThisBinding() Whether this ER has a this binding (only FunctionER and GlobalER return true)
HasSuperBinding() Whether this ER has a super binding (only method FunctionERs return true)
WithBaseObject() Returns the binding object of an ObjectER (for Reference Record's Base); other ERs return undefined

DeclarativeEnvironmentRecord.CreateMutableBinding key spec behavior: The created binding is marked initialized: false (TDZ state) and only becomes initialized after InitializeBinding is called. GetBindingValue throws ReferenceError when the binding is uninitialized.

Specification Section 6.2.5: Reference Record

Section 6.2.5 defines the complete fields and operations of Reference Record:

GetValue(V) abstract operation:

1. If V is not a Reference Record, return V (it is already a value)
2. If V.[[Base]] is unresolvable โ†’ throw ReferenceError
3. If V.[[Base]] is an Environment Record:
   a. Call base.GetBindingValue(V.[[ReferencedName]], V.[[Strict]])
4. Otherwise (Base is an object):
   a. Call base.[[Get]](V.[[ReferencedName]], GetThisValue(V))

GetThisValue(V) abstract operation:

1. If V is a Super Reference (V.[[ThisValue]] is not empty) โ†’ return V.[[ThisValue]]
2. Otherwise return V.[[Base]]

This explains why this in super.method() is the current object rather than the parent prototype: [[ThisValue]] stores the true this, while [[Base]] stores the HomeObject (the prototype object); GetThisValue distinguishes between the two.

The GetIdentifierReference Algorithm

GetIdentifierReference(env, name, strict) is the core specification algorithm for variable lookup:

1. If env is null โ†’ return { [[Base]]: unresolvable, [[ReferencedName]]: name, [[Strict]]: strict }
2. Call env.HasBinding(name)
3. If found:
   a. Return { [[Base]]: env, [[ReferencedName]]: name, [[Strict]]: strict }
4. Otherwise:
   a. outer = env.[[OuterEnv]]
   b. Return GetIdentifierReference(outer, name, strict)  โ† recurse outward

This recursive algorithm directly corresponds to the intuitive "scope chain" model. [[Base]]: unresolvable means the binding was not found anywhere on the chain; calling GetValue on such a Reference Record throws ReferenceError.


๐Ÿ’Ž Level 4 ยท Edge Cases and Traps

Trap 1: delete Return Value Behavior Varies by Reference Type

The behavior of the delete operator entirely depends on the type of the Reference Record produced by the operand:

// Case 1: delete an object property (Base is an object)
const obj = { x: 1 };
console.log(delete obj.x);   // true โ† property deleted successfully
console.log(obj.x);          // undefined

// Case 2: delete a var-declared variable (Base is DeclarativeER, not deletable)
var a = 1;
console.log(delete a);       // false โ† cannot delete; binding in DeclarativeER
console.log(a);              // 1 โ† variable still exists

// Case 3: delete an undeclared global property (attached to the global object, Base is ObjectER)
b = 2; // in non-strict mode, b becomes a property of the global object
console.log(delete b);       // true โ† global object property can be deleted
console.log(typeof b);       // 'undefined'

// Case 4: delete a variable name in strict mode
'use strict';
var c = 3;
delete c; // SyntaxError: Delete of an unqualified identifier in strict mode.

The specification states: the delete operator inspects the Reference Record:

Trap 2: Variables Declared with var Inside eval Can Be delete'd

This is a legacy inconsistency:

// In non-strict mode, var declarations inside eval create deletable bindings
eval('var x = 1');
console.log(x);        // 1
console.log(delete x); // true โ† can be deleted!
console.log(typeof x); // 'undefined'

// Contrast: ordinary var declarations are not deletable
var y = 2;
console.log(delete y); // false
console.log(y);        // 2

Root cause: The specification states that var declarations inside eval are created via CreateMutableBinding with D = true (deletable = true); whereas var declarations in normal code go through VarDeclaredNames/InstantiateVarScopeDeclarations with D = false. This discrepancy has existed since ES5 and is retained for backward compatibility.

Trap 3: Dynamic Runtime Lookup Inside with Creates Silent Bugs

function processData(data) {
  with (data) {
    // You might intend to access the outer variable `length`
    // But when data has a `length` property, this reads data.length
    // When data lacks `length`, only then does it access the outer `length`
    console.log(length);
  }
}

const length = 10; // outer variable
processData({ length: 999 }); // prints 999 (data.length)
processData({});              // prints 10 (outer length)

The fundamental issue: with's ObjectEnvironmentRecord queries object properties at runtime, and an object's property set is dynamic. The same variable name length may refer to completely different values depending on the runtime shape of data. This makes code inside with impossible to statically analyze and prevents V8's JIT compiler from optimizing it (JIT needs to know variable locations at compile time to produce efficient machine code).

In strict mode, with is completely forbidden (SyntaxError)โ€”the correct engineering decision.

Trap 4: TDZ Is Not Just "Access Before Declaration"

The most common mental model of TDZ is "accessing a let/const variable before its declaration line throws an error." However, there is a more subtle scenarioโ€”inner TDZ shadowing an outer variable of the same name:

const x = 'outer';

{
  // In this block, x is in TDZ from the start of the block to the let declaration
  // Even though the outer scope has x, the inner x is not accessible
  console.log(x); // ReferenceError! Not 'outer'
  let x = 'inner'; // TDZ for x ends here
  console.log(x); // 'inner'
}

Specification behavior: when the engine enters a block, all let/const declarations in the block are "hoisted" (bindings are created), but their state is uninitialized (TDZ). GetIdentifierReference finds the binding for x in the current ER (in uninitialized state) and stops looking outwardโ€”it calls GetBindingValue on this uninitialized binding, which throws ReferenceError.

The outer x is completely shadowed. You cannot access it at all until the inner let x has executed.

Trap 5: Function Declaration Hoisting vs. Variable Hoisting Priority and Override Rules

// Function declaration hoisting has higher priority than var
console.log(typeof foo); // 'function' (not 'undefined')

var foo = 1;
function foo() {}

console.log(foo); // 1 (the var assignment executes at runtime)

The processing order specified by the spec (during function/script instantiation):

  1. Process function declarations: create the binding and immediately initialize it to the function object
  2. Process var declarations: if the binding already exists (occupied by a function declaration), skip (do not create a duplicate)

Therefore, when execution reaches var foo = 1, foo already has a value (the function). This line only performs the assignment foo = 1, overwriting the function value.

A more complex caseโ€”same-name function declarations inside a block (non-strict mode, browser legacy behavior):

console.log(typeof f); // 'undefined' (special hoisting rule for block-level function declarations)

{
  console.log(typeof f); // 'function' (inside the block, f is hoisted)
  function f() {}
}

console.log(typeof f); // 'function' (after the block runs, f is "synced" to the outer scope)

Block-level function declarations in non-strict mode have Web legacy semantics: the declaration is hoisted inside the block (similar to let), but after the block finishes executing, the current value is "copied" to a var binding in the outer scope. This special rule is retained solely for backward compatibility with legacy code that relies on this behavior. In strict mode, a block-level function declaration is an ordinary block-scoped binding and does not leak outward.


Summary

  1. JavaScript uses lexical scoping; variable visibility is determined by where code is written. Variable lookup follows the [[OuterEnv]] chain from the current ER outward; if the chain is exhausted without finding the binding, a ReferenceError is thrown (not undefined).

  2. The specification defines 5 types of Environment Records: DeclarativeER (let/const), ObjectER (with), FunctionER (function calls), ModuleER (ESM), and GlobalER (a composite). GlobalER combines an ObjectER (managing var and global properties) with a DeclarativeER (managing let/const/class).

  3. Reference Record is a specification-internal type representing "an unresolved reference," containing [[Base]] (an Environment Record or object) and [[ReferencedName]] (the name). GetValue retrieves the actual value from a Reference Record; PutValue writes a value into it. The behavior of the delete operator entirely depends on the [[Base]] type of the Reference Record.

  4. The with statement creates an ObjectEnvironmentRecord; property lookup is performed dynamically at runtime, blocking JIT optimization and static analysis. with is completely forbidden in strict mode.

  5. The essence of TDZ (Temporal Dead Zone) is that a binding has been created but is uninitialized (uninitialized state). When a block is entered, all let/const declarations in it are immediately created (as uninitialized). GetIdentifierReference stops searching outward once it finds a binding, even an uninitialized oneโ€”calling GetValue on it throws ReferenceErrorโ€”meaning an inner TDZ shadows an outer variable of the same name.

Rate this chapter
4.9  / 5  (14 ratings)

๐Ÿ’ฌ Comments