Execution order for class fields & constructor default parameters is surprising and inconsistent
Description:
The order of execution for constructor function default parameter initializers and instance field initializers is surprising for base classes. The instance field initializers execute before the constructor's default parameters are initialized.
Also, the order is reversed for derived classes.
The constructor's default parameters are initialized first.
The field initializers execute when the super() call is reached.
class BaseClass {
baseClassField = (
console.log('init baseClassField'),
'prints before defaultParam is initialized');
constructor(defaultParam = (
console.log('init BaseClass defaultParam'),
'prints after field is initialized'
)) {
// The spec places execution of the class instance field initializers
// first, followed by the operation, OrdinaryCallEvaluateBody, which
// executes both the processing of default parameters in the argument list
// and the execution of the function body.
//
// This order of execution is likely surprising to the programmer,
// who would expect the values of the parameters to have all been calculated
// before the class field initializers execute.
//
// To emulate this order of execution transpilers must move the default parameter
// initialization into the constructor body, so it can happen after the field
// initializers. This is a non-obvious thing for the transpiler to have to do,
// effectively transpiling a feature that "shouldn't" need to be transpiled.
}
}
class DerivedClass extends BaseClass {
derivedClassField = (
console.log('init derivedClassField'),
'prints after the default param is initialized');
constructor(
defaultParam = (
console.log('init DerivedClass defaultParam'),
'prints before derivedClassField is initialized')) {
// Since the `this` value isn't calculated until the `super()` call is
// reached, the spec cannot execute the instance field initializers before
// starting the OrdinaryCallEvaluateBody operation, so the default
// parameter initializers do run before instance field initializers for
// a derived class.
//
// The action invoked by `super()` triggers execution of the BaseClass
// constructor, followed by the instance field initializer for
// derivedClassField.
super();
}
}
console.log(`
BaseClass constructor call
========
`);
new BaseClass();
// prints:
// init baseClassField
// init BaseClass defaultParam
console.log(`
DerivedClass constructor call
========
`);
new DerivedClass();
// prints:
// init DerivedClass defaultParam
// init baseClassField
// init BaseClass defaultParam
// init derivedClassField
This inconsistent behavior seems to be an artifact of how the spec was written rather than an intentional choice. If initializing a function's parameters were defined as a separate operation from executing the function's body, this could be resolved.
OK, "untranspilable" is not correct. One could explicitly transpile the default parameter initializations as well for a base class, moving them to the constructor body.
That just feels wrong, because they shouldn't need to be transpiled.
I removed "untranspilable" from the title, but for now I've left it in the description.
Now I've updated the description to be more accurate about the effect of this behavior on transpilation.
It was intentional that field initializers run before the constructor for base classes, and runs immediately after super() for derived classes. Expressions in parameter defaults are logically part of the constructor, and inserting other logic (i.e., evaluation of field initializers) between the parameter defaults and the body of the constructor would be very strange. I can't imagine any reason to do that other than to make transpilation marginally easier at the cost of making runtime semantics weirder, which is not generally the tradeoff we make.
So, I think that @bakkot is saying that the spec behavior makes sense if you have this mental model of how constructors work.
- Default parameter initialization is part of function execution.
- Instance field initialization is part of initializing the
thisvalue. - For base classes, the
thisvalue is initialized first, then the constructor is executed. - For derived classes, the constructor starts executing first and the
thisvalue is initialized whensuper()is reached.
I think my mental model (and those of others I spoke with before writing up this issue) was that default parameter initialization happens before function execution as part of the machinery of figuring out what values the caller wants to pass. All other logic related to the execution of the function, including constructing this if necessary, would come after that.
I agree that if you look at @bakkot's mental model for base classes in isolation, it's not unreasonable. But it doesn't generalize.
To provide a more concise example:
new class {
field = console.log('field 1');
constructor(param = console.log('param 1')) {}
} // "field 1, param 1"
new class extends class {} {
field = console.log('field 2');
constructor(param = console.log('param 2')) { super(); }
} // "param 2, field 2"
I would expect nearly every JS developer to look at these two classes and predict that they should behave indistinguishably. I believe the predominant mental model is that because subclasses have an explicit location inside the constructor body where this is initialized, that base classes also have an analogous (implicit) "initialize this" step at the very top (but within the body) of their constructor.
If you are looking at that code it is possible you could expect those classes to behave identically, sure, because you're not thinking about the fact that this initialization for derived classes happens at the super() call (which is a weird fact, but goes back to ES2015 classes). If you are aware of that fact and you make a trivial modification it becomes obvious that they cannot behave identically:
new class {
field = console.log('field 1');
constructor(param = console.log('param 1')) {
console.log('body 1');
}
} // "field 1, param 1, body 1"
new class extends class {} {
field = console.log('field 2');
constructor(param = console.log('param 2')) {
console.log('body 2');
super();
}
} // "param 2, body 2, field 2"
at which point I expect most developers will correctly assume that the "body" statements run immediately after the "param" statements in both cases, because there is literally no code between them. There is no reason to believe that the this initialization in base classes somehow interposes between the evaluation of parameter defaults and the rest of the constructor body. (And indeed it is observable that it does not even without class fields: in base classes parameter defaults can refer to this.)
@bakkot after your original post above, I also realised, and confirmed with tests, that the implication was one could use this in default parameters for base classes, but it would be a runtime error to do that for a derived class.
I wish it were a runtime error for both cases, because it seems like a crazy thing to do.
I will confess that I have not yet been able to imagine a reasonable code pattern that would trip over this implementation detail. If I could, I would push harder than I am to change it. OTOH, if I could, then it would be more likely that there is existing code that will be broken by changing this behavior.
You can use this in default params of derived classes, as long as you follow the general rule that you cannot access this before calling super():
class A {}
class B extends A {
field = 1;
constructor(x = super(), y = this.field) {
console.log(y);
}
}
new B() // logs 1
OMG