5

I was reading the steps in the specification (12th edition) that occur when a class declaration or expression is used, and saw that it starts off by creating a class scope at section 15.7.7 Runtime Semantics: ClassDefinitionEvaluation:

1. Let env be the LexicalEnvironment of the running execution context.
2. Let classScope be NewDeclarativeEnvironment(env).
...

I can then see that this algorithm in the spec then creates and sets the classBinding as a binding of the classScope envrionment record (at steps 3a, and 19a), but other than that, I can't seem to see what else it is being used for. If that's the case, it seems like creating a classScope would only be needed for class expressions (where the class name is available from only within the class and not in the scope its declared) and not class declarations. I'm confused as to why classScope is created for class declarations also (15.7.8 for class declarations runs the above algorithm 15.7.7) when the class name binding is added to the surrounding scope, perhaps it's something to do with when a class uses extends?

Shnick
  • 1,199
  • 1
  • 13
  • 32

2 Answers2

4

Even setting aside modern features added after that annual snapshot spec was published (1, 2), class declarations still needed class scope to handle the class binding correctly within the class.

You noted that a class declaration creates a binding for the class name in the scope containing the declaration, whereas a class expression doesn't:

class Example1 { }
console.log(typeof Example1); // "function"
const x = class Example2 { };
console.log(typeof Example2); // "undefined"

While that's true, the inner binding in the class scope is still created, and that's important for resolving the class binding correctly within it. The constructor and methods within the class shouldn't have to rely on that external binding for the class binding, not least because that external binding is mutable:

"use strict";
// Class declaration, creates binding in the current scope
class Example {
    method() {
        // Note this uses `Example`
        return new Example();
    }
}

// **BUT**, code can change that binding
const OldExample = Example;
Example = {};

const e1 = new OldExample();
// Should this fail because `Example` has been reassigned, but it's used by `method`?
const e2 = e1.method();
// No, it works just fine
console.log(e2 instanceof OldExample); // true

If method relied on the outer binding of Example, it would be using the wrong thing when it did new Example. But thanks to class scope, it doesn't rely on that binding, it relies on the inner binding for it within the class scope (which is immutable).

As I mentioned, class fields and methods make further use of the class scope, but it was needed even before they were added.

One tricky bit is this sequence you pointed out in a comment on my previous incorrect answer:

  • 5.a. Set the running execution context's LexicalEnvironment to classScope.
  • 5.b. Let superclassRef be the result of evaluating ClassHeritage.
  • 5.c. Set the running execution context's LexicalEnvironment to env.

Why set the running execution context's LexicalEnvironment to classScope just to evaluate ClassHeritage?

Bergi came up with the answer to that, and it's the same answer as for method above: So that the class's binding is properly resolved within ClassHeritage. His wonderfully-succinct example uses a proposal (static methods) that hadn't landed in that spec yet, but it still makes the point:

// shows the class
(class X extends (class Y { static logX() { console.log(X); } }) { }).logX();

That shows a class expression for class X that extends class Y, where class Y is defined within the ClassHeritage syntax production — and refers to X! (Is this a good idea? Probably not. But there may be very edgy edge cases.)

Just for clarity, let's expand that a bit and stick to features in the spec you linked to:

const x = new class X extends (
    class Y {
        logX() {
            console.log(X);
        }
    }
) {
};
x.logX();               // shows the class
console.log(typeof X);  // undefined

So there we are, even class declarations have use for classScope, even before class fields and such were added.

T.J. Crowder
  • 1,031,962
  • 187
  • 1,923
  • 1,875
  • 1
    T.J. - Thank you so much for this brilliant answer and your time on this, very well explained. You've answered my question perfectly (I didn't realise `class Example {` was creating a mutable binding until now, for some reason I always though it was immutable like `const`) – Shnick May 12 '22 at 13:58
  • 1
    @Shnick - Very happy to, interesting question! Yeah, that business of two bindings is subtle. :-) (Apologies for getting the first answer completely wrong. :-| Needed more coffee.) – T.J. Crowder May 12 '22 at 14:02
-1

class Example1 { }
console.log(typeof Example1); // "function"
const x = class Example2 { };
console.log(typeof Example2); // "undefined"
Tu Smokes
  • 1
  • 1
  • 2
    Your answer could be improved with additional supporting information. Please [edit] to add further details, such as citations or documentation, so that others can confirm that your answer is correct. You can find more information on how to write good answers [in the help center](/help/how-to-answer). – Community May 22 '22 at 23:24
  • Thank you for taking the time to answer, but this doesn't answer my question. I'm aware the name used in class exprssions (`Example2`) doesn't get added as a binding to the scope its defined in. My question above is asking why class declarations need to their own scope created when the binding is added to the surrounding scope already. T.J.'s answer provides a good explanation as to why that is needed. – Shnick May 23 '22 at 09:53