How it works
ClassDefinitionEvaluation does the following, in order:
- Creates a new declarative environment (
classEnv) holding an immutable binding of the class name, and a new PrivateEnvironment with one fresh Private Name for every #name in the body.
- Evaluates
extends, if any. protoParent is superclass.prototype and ctorParent is the superclass itself. With no extends they're Object.prototype and Function.prototype. extends null gives protoParent = null. A non-constructor throws a TypeError.
- Creates
proto with OrdinaryObjectCreate(protoParent).
- Builds the constructor function from your
constructor method (or a default one), marks it as a class constructor, and sets its own [[Prototype]] to ctorParent. That second link is why static members are inherited.
- Defines
constructor on proto as non-enumerable via DefineMethodProperty.
- Walks the elements: instance methods go on
proto, static methods on the constructor, instance fields are collected into the constructor's [[Fields]], and private methods go in [[PrivateMethods]].
- Initialises the inner class binding, installs static private methods, then runs static fields and static blocks in order.
Note step 7: the inner binding is initialised before static fields run, so a static initializer can refer to the class by name. The outer declaration binding is initialised only after the whole class has been evaluated.
class Config {
static defaults = { retries: 3 };
static current = Config.defaults; // inner binding already initialised
}
console.log(Config.current.retries); // → 3
Edge cases
[[Call]] on an ECMAScript function begins by checking [[IsClassConstructor]] and throwing a TypeError if it's set. That check runs before this is bound or any body code executes. This is why Point.call(obj) fails too, so the ES5 pattern Parent.call(this, ...) cannot be used with class parents.
[[Construct]] branches on [[ConstructorKind]]:
base: it creates this with OrdinaryCreateFromConstructor(newTarget, "%Object.prototype%"), runs InitializeInstanceElements (private methods, then fields) and only then your body.
derived: it creates nothing. this stays uninitialized until super(...) calls Construct(parent, args, newTarget) and binds the result. A second super() throws because BindThisValue refuses to rebind.
A derived constructor may return an object, which replaces this; returning anything else but undefined throws a TypeError. A base constructor ignores non-object returns.
Methods defined by class syntax (or object shorthand) have no [[Construct]] and no own prototype, so new instance.method() throws.
class Base { ready = true; }
class Child extends Base {
constructor() {
try { this.x = 1; } catch (e) { console.log(e.name); } // → ReferenceError
super();
console.log(this.ready); // → true
}
}
new Child();
Trade-offs
Use the model "classes are a stricter surface over prototypes". For reasoning about lookup, instanceof, or Object.getPrototypeOf, think prototypes. For reasoning about construction, calling, this and privacy, think class semantics. Transpiling to ES5 can only approximate these rules with helpers, which is why compiled output has historically differed from native in edge cases (for example fields using assignment instead of definition).