I’m currently working on a browser extension to manage opened tabs and I notice that in JS ES polymorphism works a bit strange when I declare class fields at the top of the class.
Let say that we want to use polymorphism in object initialization.
E.g. we have base class View:
class View { _viewModel; constructor(viewModel) { this._viewModel = viewModel; this.init(); } init() { } }
and derived class TabView:
class TabView extends View { _title; constructor(viewModel) { super(viewModel); } init() { this.title = "test"; } get title() { return this._title; } set title(value) { this._title = value; } }
Now lets try to call simple script in index file to debug this example.
const tabView = new TabView("model"); console.log(tabView.title);
Call stack for this example looks right(read from up do down):
- TabView constructor
- View construstor (invoked by super())
- TabView init() (View constructor invoke overwriten init method from TabView)
Expected values for TabView:
- _viewModel: “model”
- _title: “test”
This example values for TabView:
- _viewModel: “model”
- _title: “undefined”
When I debug this example, it looks like when init()
method is invoked from View
then this
refers to View
class instead of TabView
. The value was saved in View
instance, and TabView
field was still ‘undefined’. When I removed _title
field from the top of TabView
class then everything works as I want. Result was the same for the newest version of Firefox and Microsoft Edge.
I like to have class fields written at the top, so I want to ask if is it correct behavior of JS ES or maybe it is a bug that maybe will be correcter in future version of ECMA Script?
Advertisement
Answer
When I debug this example, it looks like when
init()
method is invoked fromView
thenthis
refers toView
class instead ofTabView
. The value was saved inView
instance, andTabView
field was still'undefined'
.
Take a look at this code:
class View { _viewModel; constructor(viewModel) { this._viewModel = viewModel; this.init(); } init() { console.log("View init"); } } class TabView extends View { _title; constructor(viewModel) { super(viewModel); } init() { console.log("TabView init"); this.title = "test"; } get title() { console.log("get title"); return this._title; } set title(value) { console.log("set title"); this._title = value; } } const tabView = new TabView("model"); console.log(tabView.title);
This logs
TabView init set title get title
Which means the constructor calls init
from TabView
which in turn calls the setter for title
.
The reason _title
is undefined
in the end is the specification for class fields (a Stage 3 proposal, at the time of writing). Here is the relevant part:
Fields without initializers are set to
undefined
Both public and private field declarations create a field in the instance, whether or not there’s an initializer present. If there’s no initializer, the field is set to
undefined
. This differs a bit from certain transpiler implementations, which would just entirely ignore a field declaration which has no initializer.
Because _title
is not initialised within TabView
, the spec defines that its value should be undefined
after the constructor finishes executing.
You have a few options here but if you want to declare _title
as a class field and have a different value for it, you have to give the field a value as part of the TabView
instantiation, not as part of its parent (or grandparents, etc.).
Field initialiser
class TabView extends View { _title = "test"; //give value to the field directly constructor(viewModel) { super(viewModel); } /* ... */ }
class View { _viewModel; constructor(viewModel) { this._viewModel = viewModel; this.init(); } init() { } } class TabView extends View { _title = "test"; //give value to the field directly constructor(viewModel) { super(viewModel); } get title() { return this._title; } set title(value) { this._title = value; } } const tabView = new TabView("model"); console.log(tabView.title);
Initialise the value in the constructor
class TabView extends View { _title; constructor(viewModel) { super(viewModel); this._title = "test"; //give value to `_title` in the constructor } /* ... */ }
class View { _viewModel; constructor(viewModel) { this._viewModel = viewModel; this.init(); } init() { } } class TabView extends View { _title; constructor(viewModel) { super(viewModel); this._title = "test"; //give value in the constructor } get title() { return this._title; } set title(value) { this._title = value; } } const tabView = new TabView("model"); console.log(tabView.title);
Call a method that initialises the field
class TabView extends View { _title; constructor(viewModel) { super(viewModel); this.init(); //call `init` which will give value to the `_title` field } init() { this.title = "test"; } /* ... */ }
class View { _viewModel; constructor(viewModel) { this._viewModel = viewModel; this.init(); } init() { } } class TabView extends View { _title; constructor(viewModel) { super(viewModel); this.init(); //call `init` which will give value to the `_title` field } init() { this.title = "test"; } get title() { return this._title; } set title(value) { this._title = value; } } const tabView = new TabView("model"); console.log(tabView.title);
Remove the field declaration
class TabView extends View { //no declaration here constructor(viewModel) { super(viewModel); } /* ... */ }
class View { _viewModel; constructor(viewModel) { this._viewModel = viewModel; this.init(); } init() { console.log("View init"); } } class TabView extends View { //no declaration here constructor(viewModel) { super(viewModel); } init() { this.title = "test"; } get title() { return this._title; } set title(value) { this._title = value; } } const tabView = new TabView("model"); console.log(tabView.title);