Allow accessing classes before initialization

Consider the following files:

index.html:

<script type="module" src="main.js"></script>

main.js:

import Base from "./base.js";
import Derived from "./derived.js";
window.addEventListener("load", () => {
    //Do stuff with Base and Derived
});

base.js:

import Derived from "./derived.js";
export default class Base {
    foo(){
        return new Derived();
    }
}

derived.js:

import Base from "./base.js";
export default class Derived extends Base {}

If you try running this code, you will get an error saying "Uncaught ReferenceError: Cannot access 'Base' before initialization".

In this simple example, the solution is relatively easy: change the order of the two imports in main.js. But in a larger project it's much more difficult. In a very large project with about 100 files and multiple class hierarchies, I spent hours debugging problems like this, which was extremely frustrating.

Since functions can be accessed before initialization, the same thing should be possible with classes. The equivalent pre-ES6 code (but still with ES6 modules) works just fine without reordering any imports:

base.js:

import Derived from "./derived.js";
export default function Base(){}
Base.prototype.foo = function(){
    return new Derived();
}

derived.js:

import Base from "./base.js";
export default function Derived(){}
Object.setPrototypeOf(Derived.prototype, Base.prototype);

It would be nice if ES6 classes could work the same way, that they can be accessed before initialization, at least for purposes of inheritance. In large projects this would save a lot of frustration and hours of debugging.

Classes can't be hoisted because the initialization of a class can have side effects, because of static fields and static initialization blocks. And also it may have dependencies on other stuff that do have uninitialized states. Consider:

console.log(Foo.bar); // ??

const x = 1;

class Foo {
  static bar = x;
}

This problem doesn't exist with functions.

1 Like

To add to this, specifically with respect to extends, the expression in the extends clause needs to be evaluated when the class is created since static initialization could depend on it.

class Derived {
  static foo = 1
}

class Base extends Derived {
  static bar = this.foo // <-- foo coming from Derived
}

The equivalent pre-ES6 code (but still with ES6 modules) works just fine without reordering any imports

What's interesting about this example is that Derived may not be fully defined when you think it is. Because its relying on the hoisted Base function definition, the full Base prototype has not yet been defined when setting up inheritance for Derived. So if you were try to create a Derived instance within the derived module, it would not be fully functional.

derived.js:

import Base from "./base.js";
export default function Derived(){}
Object.setPrototypeOf(Derived.prototype, Base.prototype);
console.log(typeof new Derived().foo); // "undefined" (expected "function")

The same log in main would result in "function" because the Base prototype would be defined by then. The current behavior of classes ultimately helps you to not fall into this kind of trap.

1 Like

All the issues you mention seem to be related to static members. Would it be possible to allow accessing a class itself before initialization, but not static members?

That way the cases that you mention would work more or less like they do currently, but simply inheriting a class would work as expected. That should solve this issue in the most common cases, since inheritance is very common but classes initializing static fields with another class' static fields is a lot less common.

Maybe methods (both static and non-static, as well as the class itself) could be initialized as functions, but static variables could be initialized as regular variables?

Then the cases you mention would work like this:

console.log(Foo.bar); // Error: cannot access Foo.bar before initialization
                      // (Notice that it's only Foo.bar that it can't access, not Foo as currently)
const x = 1;

class Foo {
  static bar = x;
}
class Derived {
  static foo = 1
}

class Base extends Derived {    //OK, should be able to inherit from Derived before initialization
  static bar = this.foo // <-- Error: cannot access this.foo before initialization
}

The second example would be similar to constructor bodies of derived classes where you must call super() to be able to access this. In this case, the superclass must be initialized before being able to access this.

What's interesting about this example is that Derived may not be fully defined when you think it is. Because its relying on the hoisted Base function definition, the full Base prototype has not yet been defined when setting up inheritance for Derived. So if you were try to create a Derived instance within the derived module, it would not be fully functional.

That's interesting. I'm guessing it's because Base.prototype.foo hasn't been assigned to yet (since it's an assignment, not a function declaration). Since class methods are declarations and not assignments, they could be initialized like regular functions as well, avoiding this issue with classes without errors.

The only case I can think of where this would cause a problem is if the constructor or some other method uses static variables declared in the base class, in which case a similar error to what I suggest above could be thrown:

base.js:

import Derived from "./derived.js";
export default class Base {
    static bar = 1;
    foo(){
        return new Derived();
    }
}

derived.js:

import Base from "./base.js";
export default class Derived extends Base {
    baz(){
        return Base.bar;
    }
}

const d = new Derived();
d.foo();    // OK, Base.foo is a function and can be accessed before initialization
d.baz();    // Error when baz() attempts to use Base.bar: Base.bar cannot be accessed before initialization

This idea would still cause errors in some edge cases involving static variables, but would still solve the problem in the most common case with simple inheritance. Also the errors that do happen in these edge cases wouldn't be any harder to fix than the current errors (not that they would be easy to fix, but the current errors aren't either easy to fix).

You will be able to resolve this more easily with defered imports.

e.g. With the stage 3 syntax:

import defer * as m from "./Derived.js";

export class Base {
    static foo() { return new m.Derived(); }
}
import Base from "./Base.js";

export default class Derived extends Base {}

This pattern will work regardless of the order of imports as the defer means "Base.js" will always be the cycle root. It's mildy annoying that you need to add the extra module identifiers (m in this example), but otherwise it works as expected.

2 Likes