javascripttypescriptconstructorecmascript-2020

Overwriting default values in parent constructor in javascript/typescript


I have an abstract Typescript class with a constructor. This constructor accepts an object and then tries to set properties of the class with the data in the object.

export default class AbstractModel {
    constructor(properties: object) {
        Object.assign(this, properties);
    }
}

Then there are several classes inheriting this AbstractModel class. For example:

import AbstractModel from "./abstract-model";

export default class Derived extends AbstractModel {
    firstname!: string;
    lastname!: string;
    age: number = 1;
}

My assumption was that when I create a new Derived and pass {age: 10} in the constructor the newly created Derived would have age set to 10. However, age always seems to be the initial value of 1. Other properties without an initial values are being set as expected.

const derived = new Derived({firstname : "John", lastname: "Doe", age: 10});

Output:

Derived {
  firstname: 'John',
  lastname: 'Doe',
  age: 1,
}

Logging this in the constructor of AbstractModel gives me the following output

export default class AbstractModel {
    constructor(properties: object) {
        Object.assign(this, properties);
        console.log(this);
    }
}

Output:

Derived {
  firstname: 'John',
  lastname: 'Doe',
  age: 10,
}

Logging this in the constructor of Derived gives me the following output

export default class Derived extends AbstractModel {
    firstname!: string;
    lastname!: string;
    age: number = 1;

    constructor(properties: object) {
        super(properties);
        console.log(this);
    }
}

Output:

Derived {
  firstname: 'John',
  lastname: 'Doe',
  age: 1,
}

Can anyone tell me why it is behaving like this?


Solution

  • The initial value you provide for age is assigned/defined¹ by your subclass constructor. That assignment/definition is done just after the call to super in your subclass constructor (either your explicit constructor if you provide one, or the default generated one if you don't). As a result, age will be 1 in your subclass, because the assignment done by the superclass constructor gets overwritten. Here's a simpler example (just using JavaScript so we can run it in Stack Snippets):

    class Base {
        constructor() {
            this.age = 42;
        }
    }
    class Derived extends Base {
        age = 1;
    }
    
    console.log(new Derived().age);

    The generated constructor for Derived is:

    constructor(...args) {
        super(...args);
    }
    

    ..and the definition/assignment¹ setting age to 1 happens just after the super(...args) part of that.

    I think the minimal-changes way to fix it in the code you've shown is to remove the default value and provide a constructor for Derived:

    age: number;
    constructor(obj: object) {
        super(obj);
        this.age = (obj as any).age ?? 1;
    }
    

    or

    age!: number;
    // ^−−− Note the "definitely assigned" assertion
    constructor(obj: object) {
        super({age: 1, ...obj});
    }
    

    or similar (playground link).

    That said, the AbstractModel constructor code isn't typesafe, it'll copy over any and all own properties of the object you provide it. You may want to refactor that.


    ¹ Whether Derived does an assignment (effectively, this.age = 1) or a redefinition (Object.defineProperty(this, "age", {value: 1, /*...*/})) depends on the useDefineForClassFields configuration option. It does assignment when the option isn't enabled, and redefinition when it is. Redefinition is the way JavaScript's class fields are standardized, so the configuration option was added to TypeScript to support that newly-standard behavior when class fields were introduced to JavaScript.