1
// a.js
import * as bModule from './b.js'
export const b = bModule

// b.js
import * as aModule from './a.js'
export const a = aModule

If a.js is an entry point of our app then b.js would run firstly. Calling of console.log(aModule) from b.js at this stage results in such output:

[Module] {
  b: <uninitialized>,
}

And if b.js try to access aModule.b it will end up with the ReferenceError: b is not defined.

ReferenceError is thrown when a code uses an identifier (variable) hasn't declared yet. However, in this case aModule does have the property b (the fact is borne out by console.log). Moreover, when we access an uninitialized object property we just get the undefined value but not the exception.

So how this behavior can be understood? Is it specified?

Ilya Loskutov
  • 1,967
  • 2
  • 20
  • 34

2 Answers2

3

This behavior is because of the temporal dead zone.

Unlike identifiers declared using var, identifiers declared using let or const are marked as "not yet initialized" and they can't be accessed until their declaration has actually executed during step-by-step execution of the javascript code.

Since b in a.js is defined with const, it is hoisted but you can't access it before its declaration has actually executed. Same would be the case if b was declared with let.

If you declare b using var, then you would see undefined as the output.

Yousaf
  • 27,861
  • 6
  • 44
  • 69
2

ReferenceError is thrown when a code uses an identifier (variable) hasn't declared yet.

Not only then. :-)

Moreover, when we access an uninitialized object property we just get the undefined value but not the exception.

Yes, but modules are modern constructs, and you're using those another modern construct: let. It's defined to fail if used before it could be initialized, rather than provide ambiguous values.

Yes, it's specified behavior. Modules go through a series of phases, and during that process their exports of let, const, or class bindings are created as uninitialized bindings, and then later those exports are initialized with values. When modules have cycles (circular dependencies), it's possible to see an export before it's initialized, causing a ReferenceError.

It's the same Temporal Dead Zone (TDZ) we can see here:

let a = 21;
console.log(a); // 21
if (true) {
    console.log(a); // ReferenceError
    let a = 42;
}

The declaration for a within the if block declares it throughout the block, but you can't access it until the declaration is reached in the step-by-step execution of the code. You can see that the declaration takes effect throughout the block by the fact that we can't access the outer a from inside the block.

The same TDZ applies to exports that haven't been initialized yet.

You'll only have this problem with top-level code in a module, since the module graph will be fully resolved before any callbacks occur. Your code in b.js would be safe using a.b in a function called in response to an event (or called from a.js).

If you needed to use a.b in b.js at the top level, you'd need to run the call after b.js's top-level code execution was complete, which you can do via setTimeout or similar:

setTimeout(() => {
    console.log(a.b); // Shows the `b` module namespace object's contents
}, 0);
T.J. Crowder
  • 1,031,962
  • 187
  • 1,923
  • 1,875
  • 1
    FWIW, I go into the mechanics of modules, including the TDZ and cycles, in Chapter 13 of my recent book *JavaScript: The New Toys* (the TDZ in relation to `let`, `const`, and `class` is in Chapters 2 and 4). Links in my profile if you're interested. – T.J. Crowder Feb 07 '21 at 10:37