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: function scopeโcreates a binding in the nearest function ER (or Global ER), unaffected by{}blockslet/const: block scopeโcreates a binding in the ER of the nearest{}blockfunctiondeclarations: function scope (inside a function body), hoisted to the top of the enclosing function or global scope
{
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:
[[ThisValue]]: thethisbound when the function is called[[ThisBindingStatus]]:'lexical'(arrow functions, no ownthis) /'initialized'/'uninitialized'[[HomeObject]]: the reference used bysuperto locate the prototype[[NewTarget]]: the value ofnew.target
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:
delete foo.bar:foo.barproduces{ Base: foo, ReferencedName: 'bar' }โ deletable (property deletion)delete bar:barproduces{ Base: EnvironmentRecord, ReferencedName: 'bar' }โ not deletable (ER binding)
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:
[[Base]]is an Environment Record and strict mode โ SyntaxError (reported at parse time)[[Base]]is an Environment Record and non-strict mode โfalse(no deletion, returns false)[[Base]]is an object โ call the object's[[Delete]]method
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):
- Process
functiondeclarations: create the binding and immediately initialize it to the function object - Process
vardeclarations: if the binding already exists (occupied by afunctiondeclaration), 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
-
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, aReferenceErroris thrown (notundefined). -
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).
-
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 thedeleteoperator entirely depends on the[[Base]]type of the Reference Record. -
The
withstatement creates an ObjectEnvironmentRecord; property lookup is performed dynamically at runtime, blocking JIT optimization and static analysis.withis completely forbidden in strict mode. -
The essence of TDZ (Temporal Dead Zone) is that a binding has been created but is uninitialized (
uninitializedstate). When a block is entered, alllet/constdeclarations in it are immediately created (as uninitialized).GetIdentifierReferencestops searching outward once it finds a binding, even an uninitialized oneโcalling GetValue on it throwsReferenceErrorโmeaning an inner TDZ shadows an outer variable of the same name.