Isn’t polymorphism works wrong in JS ES

Tags: , , ,



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?

Answer

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'.

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);


Source: stackoverflow