cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

Environment Records: how scope is specified

Read the ECMAScript model of bindings, the global object, this, module imports, private names, and outward lookup with small runnable examples.

By the end, you can
  • 01
    Read binding stepsExplain why a created lexical binding can throw before initialization, even when an outer name exists.
  • 02
    Compare record kindsDistinguish declarative, object, global, function, module, and private-name records without confusing them with objects.
  • 03
    Trace outward lookupFollow [[OuterEnv]] and connect a closure or imported name to the binding it actually reads.

Names need a home

In JavaScript, two variables can have the same spelling but hold different values. The specification needs a way to say which one a read means. It uses Environment Records: specification-only records that associate names with bindings. A binding is the association for a name, including its value and whether that value is ready to read.

Two users, two bindingsPop out in the code editor (opens in a new tab)JavaScript
const user = "Asha";
{
  const user = "Sam";
  console.log(user);
}
console.log(user);

Line 1 stores Asha in an outer binding. Line 3 creates another user inside the braces. Line 4 logs Sam; line 6 logs Asha. The nearer binding wins only while that block is in scope.

Definition

An Environment Record is the specification's way to connect an identifier to a binding. It is not a JavaScript object or an engine data structure you can inspect. The spec explicitly allows implementations to represent it differently.

You have already used the idea in scope and lexical environments, closures, and the temporal dead zone. Here we read the precise rules instead of reteaching those introductions. The preceding Reference type lesson explains what a resolved name produces; this one explains the record where lookup finds it.

Declarative Environment Records

A Declarative Environment Record directly stores names introduced by declarations. Think of a block with let and const: the two bindings belong to that block, not to a property bag on a JavaScript object. Function and module records are specialized declarative records.

A mutable and an immutable bindingPop out in the code editor (opens in a new tab)JavaScript
let price = 3;
price = 4;
const order = "tea";
console.log(price, order);

Line 1 creates and initializes mutable price to 3. Line 2 changes it to 4. Line 3 initializes immutable order to tea. Line 4 prints 4 tea. Immutable means the binding cannot later be reassigned; it does not freeze objects stored in the binding.

Real-life analogyA school attendance register

Your school keeps attendance by class. A name in one class's register does not replace the same name in another class's register. Each register gives the teacher a place to look first.

In real life: A line has a student's name
In JavaScript: A record associates a name with a binding
In real life: The teacher fills in attendance
In JavaScript: Initialization supplies the first value
In real life: The same name appears in another class
In JavaScript: Another record can own that spelling

Where the analogy stops: A real register is paper. An Environment Record is only a specification mechanism.

The spec's declarative record also covers catch parameters and declarations in other lexical scopes. The important distinction is how names are stored: directly as bindings, not as properties of an arbitrary object. That distinction will matter for global code.

Creating and initializing bindings

Creating a lexical binding and giving it its first value are different spec actions. Uninitialized means a binding already exists but has no readable value. This is the source of the temporal dead zone (TDZ): reading the name before its declaration is evaluated throws a ReferenceError.

Read before and after initializationPop out in the code editor (opens in a new tab)JavaScript
{
  try { console.log(price); }
  catch (error) { console.log(error.name); }
  let price = 3;
  console.log(price);
}

Line 2 tries to read price before its initializer runs, so the catch logs ReferenceError. Line 4 initializes the existing binding to 3. Line 5 then prints 3. The earlier read does not fall back to another outer name: the local binding was already found.

These are shortened, labeled excerpts of the specification's binding operations, not runnable JavaScript. In the actual declarative algorithms, CreateMutableBinding and CreateImmutableBinding both record an uninitialized name; InitializeBinding marks it initialized, and GetBindingValue checks that state before returning a value.

Selected spec steps: declarative bindings (shortened)Text
CreateMutableBinding("price", false): create an uninitialized mutable binding.
CreateImmutableBinding("user", true): create an uninitialized immutable binding.
InitializeBinding("price", 3): set its value and mark it initialized.
GetBindingValue("price", true): if uninitialized, throw ReferenceError; otherwise return its value.
Four binding operations: creation is not a read
OperationWhat it doesWhat you observe
CreateMutableBindingCreates a mutable, uninitialized bindingA let name is reserved before its initializer
CreateImmutableBindingCreates an immutable, uninitialized bindingA const name is reserved before its initializer
InitializeBindingSets the first value and marks it initializedThe declaration is evaluated
GetBindingValueReturns a value, or throws if uninitializedA read during the temporal dead zone throws

The spec also has SetMutableBinding for later assignments. Do not mistake the first initialization for a later write: the first gives a reserved name its value, whereas a later assignment changes an initialized mutable binding. Read the normative declarative record algorithms alongside the runnable example.

Object Environment Records

An Object Environment Record looks up identifier names through an object's properties. A legacy with statement demonstrates this directly. It is banned in strict mode, so do not introduce it into new application code; its role here is to make the specification's object-backed record visible.

Legacy with reads an object propertyPop out in the code editor (opens in a new tab)JavaScript
const order = { price: 3 };
with (order) {
  console.log(price);
}
console.log(order.price);

Line 1 creates an object with a price property. Line 2 starts a legacy with scope using that object. Line 3 prints 3, because the property supplies the name. Line 5 reads the property explicitly and also prints 3. The object is real; its Environment Record is a specification-only way to explain name lookup.

An object-backed record can see inherited properties too. Its set of available names can change when properties are added or removed. For with, Symbol.unscopables can exclude a property from name lookup. These extra rules are one reason to prefer clear order.price property access in everyday code.

Do not confuse the two

The block's const order is a declarative binding that holds an object. The with (order) statement creates a separate object-backed record that offers that object's properties as identifier names. Merely storing an object in a variable does not create an Object Environment Record.

Object records also appear inside the global record, but there the binding object is the global object, not this order. The same record kind serves two different contexts. The spec's object record rules describe the property lookup and the special with behavior.

The two parts of a global record

A Global Environment Record is one logical record with two components. Its object record uses the global object for top-level classic-script var and function declarations. Its declarative record holds top-level let, const, and class declarations. This distinction is for classic scripts, not modules.

Classic script: property versus lexical bindingPop out in the code editor (opens in a new tab)JavaScript
var cart = "tea";
function checkout() { return cart; }
let price = 3;
const user = "Asha";
console.log(globalThis.cart, globalThis.checkout());
console.log(globalThis.price, globalThis.user);

Line 1 declares cart with var; line 2 declares checkout. Lines 3 and 4 declare lexical names. Line 5 prints tea tea through global-object properties; line 6 prints undefined undefined for globalThis.price and globalThis.user. Reading price by name still gives 3.

This example must run as a top-level classic script. Code wrapped by a bundler, function, or module is not the same test. The lesson test runs it in a fresh script context; its globalThis is that context's global object, not a browser window. In the browser editor, run the block as a script, not inside a module. Property access on a missing name returns undefined; it does not prove the lexical name is absent.

Selected spec steps: global lookup (shortened)Text
Global HasBinding(name): check [[DeclarativeRecord]] first.
If not found, check [[ObjectRecord]].
CreateGlobalVarBinding(name, deletable): create and initialize in [[ObjectRecord]].
Where top-level declarations land
DeclarationRecordObservable consequence
Top-level var in a classic scriptObject recordUsually a global-object property
Top-level function declaration in a classic scriptObject recordA global-object property
Top-level let or const in a classic scriptDeclarative recordNot a global-object property
Top-level declaration in a moduleModule recordNot a classic-script global declaration

HasBinding checks the declarative component first, then the object component. Special operations CreateGlobalVarBinding and CreateGlobalFunctionBinding put classic-script declarations in the object component. The spec details are in Global Environment Records.

Function records and this

A Function Environment Record belongs to one function invocation and holds top-level local bindings for that call. For a non-arrow function it can also provide this. Which object is passed as this depends on how the function is called. For the Reference side of that story, see the preceding lesson.

One function, two receiversPop out in the code editor (opens in a new tab)JavaScript
"use strict";
function showOrder() { return this.order; }
const cart = { order: "tea", showOrder };
console.log(cart.showOrder());
console.log(showOrder.call({ order: "coffee" }));

Line 1 enables strict mode so the example never relies on a fallback global this. Line 2 defines a function that reads its receiver's order. Line 3 places that function on cart. Line 4 logs tea; line 5 explicitly supplies another receiver with call and logs coffee. Each call gets its own function record.

An arrow borrows this from its outer functionPop out in the code editor (opens in a new tab)JavaScript
const cart = {
  order: "tea",
  show() { return () => this.order; },
};
const readOrder = cart.show();
console.log(readOrder());

Line 2 supplies tea on cart. Line 3 returns an arrow from the method; the arrow does not create its own this binding. Line 5 calls show with cart as receiver. Line 6 prints tea even though the method call already returned, because the arrow can still use that invocation's this.

In the spec, HasThisBinding returns false for an arrow's lexical-this status, and true for an ordinary function record. GetThisEnvironment follows outer records until it finds one that supplies this. Function records also hold call-specific state needed for super and new.target; the next call-focused lesson goes deeper.

Module records and live imports

A Module Environment Record is a specialized declarative record for a module's top-level declarations and imports. An imported name is an indirect binding: it points to a binding in another module rather than storing a copied value. The importer cannot reassign that name, but the exporting module can change its own exported let.

cart.mjs (module file)JavaScript
export let price = 3;
export function changePrice() { price = 4; }

Line 1 exports price, initially 3. Line 2 exports changePrice, which can set that same binding to 4. This is a file to import, not a standalone classic-script snippet; the next block is its consumer.

page.mjs (run together with cart.mjs)JavaScript
import { price, changePrice } from "./cart.mjs";
console.log(price);
changePrice();
console.log(price);

Line 1 connects both imported names. Line 2 prints 3. Line 3 calls the exporter's function; line 4 prints 4. The test runs these as two actual Node module files, since a classic-script sandbox cannot execute a bare import. For a complete look at loading and linking, see module records.

Selected spec steps: indirect imports (shortened)Text
CreateImportBinding(name, targetModule, targetName): create an immutable indirect binding.
GetBindingValue(name, true): for an indirect binding, read targetEnv.GetBindingValue(targetName, true).

CreateImportBinding stores a link to a target module and name. A later GetBindingValue asks the target environment for its current value. Modules have a global record as their outer environment, but their own top-level declarations belong to the module record; module top-level this is undefined. Read the module record rules to see those branches.

PrivateEnvironment Records

A PrivateEnvironment Record tracks the Private Names declared by a class, such as #price. It is similar in purpose to an Environment Record, but is a different specification mechanism. It does not map ordinary identifier names like price to normal variable bindings.

A class's private pricePop out in the code editor (opens in a new tab)JavaScript
class Cart {
  #price = 3;
  total() { return this.#price; }
}
console.log(new Cart().total());

Line 1 starts the class. Line 2 declares its private field #price with value 3. Line 3 reads it in total; line 5 logs 3. An ordinary property named price would not be the same private name, and code outside the declaring class cannot spell cart.#price to read it.

Selected spec steps: private-name lookup (shortened)Text
NewPrivateEnvironment(outer): record [[OuterPrivateEnvironment]] and an empty [[Names]] list.
ResolvePrivateIdentifier(env, name): search [[Names]], then [[OuterPrivateEnvironment]].

Each private record has a [[Names]] list and a [[OuterPrivateEnvironment]] link for nested class cases. ResolvePrivateIdentifier searches those lists, not the ordinary [[OuterEnv]] chain. The spec makes this separate in its PrivateEnvironment Records section. You do not need to build these records by hand in app code.

Do not read #price as a string property that happens to begin with a hash. It is private syntax with a distinct identity. That is why a normal cart.price lookup cannot reach it. The record explains which class text owns the name; each object instance still has its own field value.

Following [[OuterEnv]]

Every ordinary Environment Record has [[OuterEnv]], a link to a logically enclosing record or null. ResolveBinding begins at the currently running code's lexical environment. It uses GetIdentifierReference to ask one record for a name; if missing, it follows that record's outer link.

Find price outside checkoutPop out in the code editor (opens in a new tab)JavaScript
const price = 3;
function checkout() {
  const order = "tea";
  console.log(order, price);
}
checkout();

Line 1 creates an outer price with value 3. Line 3 creates local order. Line 4 reads order locally and price from outside. Line 6 calls checkout, logging tea 3. The spec's lookup returns a Reference Record for the found binding, not the final value immediately.

Selected spec steps: name lookup (shortened)Text
ResolveBinding(name): start at the running context's LexicalEnvironment.
GetIdentifierReference(env, name, strict): ask env.HasBinding(name).
If found, return a Reference Record based on env.
Otherwise, try env.[[OuterEnv]]; at null, return an unresolvable Reference Record.

If no record owns the name, GetIdentifierReference returns an unresolvable Reference Record at null; reading that reference later can throw. If a nearer record owns an uninitialized name, lookup stops there and its value read throws instead. The distinction matters for the TDZ: searching outward would incorrectly reveal a shadowed outer name.

Replay an outward name lookup
Step 0 of 8Ready
Your turn: follow the blue line

A replay of the lesson's instrumented lookup model, not an engine debugger or a view of real Environment Records.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
function checkout() {  const order = "tea";  return order + ": " + price;}console.log(checkout());
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

The replay calls the lesson's lookup model and shows its recorded snapshots. It does not pause JavaScript or inspect actual engine records. Step to the local order, then watch lookup follow the outer link for price. The printed result is tea: 3 in that replay's source.

Try one local price binding
The outward lookup examplePop out in the code editor (opens in a new tab)JavaScript
const price = 3;
function checkout() {
  const order = "tea";
  console.log(order, price);
}
checkout();
Lookup result
checkout recordorder = tea
global recordprice = 3
price resolves inglobal

Without a local price, lookup follows [[OuterEnv]].

Try it yourself

The teaching model finds price in global; its value is 3.

This is a teaching model of HasBinding and [[OuterEnv]], not a view into engine memory. Reset removes the local price.

The playground changes just one thing: whether the function record also has price = 4. Its source panel shows the default runnable JavaScript; the checkbox is a labeled teaching-model variation, not an edit to that displayed code. Without the local binding, lookup finds 3 globally. With it, lookup stops locally at 4. Reset restores the first case.

Real-life analogyA phone contact list

You check your phone for a contact before asking someone at home. If your phone already has that name, you use its saved number. A matching name at home does not replace the first result.

In real life: Search your saved contacts first
In JavaScript: Check the current record with HasBinding
In real life: Ask the family contact list next
In JavaScript: Follow [[OuterEnv]] when missing
In real life: Stop on the first matching name
In JavaScript: A nearer binding shadows an outer one

Where the analogy stops: Real contact lists can be searched in any order; lexical lookup has a fixed outward chain.

Scope in an application

A website may create a cart with a private price that its UI can read later. When the factory function returns, its local call is finished, but a returned function can still read the binding. The spec connects a function's saved environment to the enclosing record; the garbage collector decides its physical storage and lifetime.

A cart remembers its pricePop out in the code editor (opens in a new tab)JavaScript
function makeCart(price) {
  return { total() { return price; } };
}
const cart = makeCart(3);
console.log(cart.total());

Line 1 takes a price parameter. Line 2 returns an object with a total method that reads that binding. Line 4 stores a cart created with 3. Line 5 logs 3. This is useful when checkout needs a saved configuration without exposing a writable global property.

The retained binding can changePop out in the code editor (opens in a new tab)JavaScript
function makeOrder() {
  let price = 3;
  return () => ++price;
}
const nextPrice = makeOrder();
console.log(nextPrice());
console.log(nextPrice());

Line 2 creates a mutable price binding. Line 3 returns a function that increments it. Line 5 keeps the function. Lines 6 and 7 log 4 and 5: the returned function reads the same retained binding each time, not two snapshots. See closures for more uses of this pattern.

Replay a retained binding
Step 0 of 12Ready
Your turn: follow the blue line

A replay of the instrumented makeOrder function; it does not inspect engine memory.

Running in
  1. script
Next: line 5
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
function makeOrder() {  let price = 3;  return () => ++price;}console.log(nextPrice());console.log(nextPrice());
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

This second replay calls the real makeOrder function and records each increment as a change. Back and Reset only change the player's view of those immutable snapshots. They do not expose internal engine allocation or guarantee a particular garbage-collection strategy.

Which kind of record owns this name?
  • let price inside a block
  • const order inside a block
  • An imported price
  • Top-level var cart in a classic script
  • price inside with (order)
  • #price declared in a class
Try it yourself
0 of 6 correct

Sort each example by where its binding or private name is specified.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

Similar syntax can hide different lookup rules. Before predicting an output, ask whether the code is a classic script or a module, whether a name belongs to a block, and whether you are reading an identifier or an object property. Those are distinct questions.

A closer price hides the outer pricePop out in the code editor (opens in a new tab)JavaScript
const price = 3;
{
  const price = 4;
  console.log(price);
}
console.log(price);

Line 1 creates outer price = 3. Line 3 creates inner price = 4. Line 4 logs 4; line 6 logs 3. Nothing overwrites the outer value. Lookup merely starts at a different record inside the braces.

  • "All variables are properties." Direct lexical bindings are not object properties.
  • "An early let read uses the outer name." The nearer uninitialized binding wins, then its read throws.
  • "Imported values are copied." An import is an indirect binding that reads the exporter's current value.
  • "A closure copies every local." The function can keep access to a live binding; engines may optimize storage.
  • "Private fields use normal scope lookup." Private names use a separate PrivateEnvironment chain.
Environment Record families
KindWhat it representsExample
DeclarativeDirect named bindings from lexical codeA block's let price
ObjectNames backed by object propertiesA legacy with (order)
GlobalComposite of object and declarative recordsTop-level classic script
FunctionDeclarative bindings plus call stateParameters and possibly this
ModuleTop-level module bindings and indirect importsimport { price }
PrivateEnvironmentPrivate names, separate from identifier bindingsA class's #price

globalThis.price is a property read. Bare price is an identifier lookup. They can have different results in the same classic script. Neither expression grants access to the Environment Record itself: the record is a rulebook device, not a public inspection API.

Practice

Use the same order each time: find the nearest record, decide whether it has the name, then ask if the binding is initialized. For property reads, ask which object the property belongs to. These small tasks use the code and output from the lesson.

Exercise 1 · Warm-upPredict a TDZ read

A checkout block reads price too early, catches the problem, and reads it again. Type both log lines in order. Explain to yourself why the first line is not undefined.

Starter codePop out in the code editor (opens in a new tab)JavaScript
{
  try { console.log(price); }
  catch (error) { console.log(error.name); }
  let price = 3;
  console.log(price);
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upFollow the nearer binding

    The page has one price outside and another inside a block. Predict the two console lines in order. Do not assume the inner declaration changes the outer value.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    const price = 3;
    {
      const price = 4;
      console.log(price);
    }
    console.log(price);

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticePick the classic-script global side

      A classic script declares a cart, a checkout function, a price, and a user. Name the kinds of declaration that become global object properties. Compare your answer with the two printed lines.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      var cart = "tea";
      function checkout() { return cart; }
      let price = 3;
      const user = "Asha";
      console.log(globalThis.cart, globalThis.checkout());
      console.log(globalThis.price, globalThis.user);

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeRead a retained price

        Your order reader is called twice after its creator returns. Predict both logged prices. Explain why it does not start over at the initial value on its second call.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        function makeOrder() {
          let price = 3;
          return () => ++price;
        }
        const nextPrice = makeOrder();
        console.log(nextPrice());
        console.log(nextPrice());

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeIdentify the private-name record

          A cart class hides its price with #price. Name the kind of specification record that tracks that private name. Then check what the method prints for a new cart.

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          class Cart {
            #price = 3;
            total() { return this.#price; }
          }
          console.log(new Cart().total());

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeApply the model to a live cart

            A shopping site imports price from its cart module. The cart module changes its exported price during checkout. Which kind of record owns the importing module's name? Describe why a second read can see the new price.

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              Predict code by separating names, records, and values. Read a property expression such as globalThis.price differently from an identifier expression such as price. The quiz mixes both.

              Environment Records quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What is an Environment Record?

                Choose an answer to see the explanation.

              2. Question 2 of 7What does this block log?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const price = 3;
                { const price = 4; console.log(price); }
                console.log(price);

                Choose an answer to see the explanation.

              3. Question 3 of 7Why can an uninitialized let binding throw before its declaration?

                Choose an answer to see the explanation.

              4. Question 4 of 7What does this classic script print for the lexical binding?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                let price = 3;
                console.log(globalThis.price);

                Choose an answer to see the explanation.

              5. Question 5 of 7What does this arrow example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const cart = { order: "tea", show() { return () => this.order; } };
                console.log(cart.show()());

                Choose an answer to see the explanation.

              6. Question 6 of 7Which statement about module import bindings is correct?

                Choose an answer to see the explanation.

              7. Question 7 of 7How does the spec look for a name missing in the current record?

                Choose an answer to see the explanation.

              For a missed question, revisit the matching example above and run it again. In particular, a TDZ error happens after lookup finds the nearer binding; it does not mean lookup could not find the name.

              Key takeaways

              Scope is not an object-property search in every case. The spec uses different records for ordinary lexical names, object-backed names, global declarations, function calls, modules, and class private names. Those records explain visible behavior without requiring any particular engine storage layout.

              • Declarative bindings can be created before they are initialized; early reads throw ReferenceError.
              • Classic-script var and functions use the global object side; let and const do not.
              • Function records can provide this; arrows follow an outer record for this.
              • Module imports read a target binding indirectly, so an exporter's changes remain visible.
              • PrivateEnvironment Records resolve class private names using a separate outer-private chain.
              • GetIdentifierReference checks the nearest record and then follows [[OuterEnv]] only if the name is missing.

              One line to remember.
              An Environment Record tells the specification which binding owns a name and where to search next.

              Coming next: Execution contexts & jobs in the spec. That lesson explains which record is the running code's starting point and how the specification organizes work over time.

              CompleteFrontend Clear concepts. Working examples.