skip to Main Content

I have gone down the "JavaScript can’t do multiple inheritance, but…" rabbit-hole and this is where I have ended up. I have got some basic functionality going, but a large part of why I am using classes is to enforce some basic validation and there are some key aspects I just can’t get working despite a lot of searching. I have done up some example code below – in this example I have a Car class and an Aircraft class and I want to combine the both into a FlyingCar class.

class Car {

    #color

    constructor() {

        Object.defineProperty(this, "Color", {
            configurable: true,
            enumerable: true,
            get: function () {
                return this.#color
            },
            set: function (value) {
                if (value && typeof value !== 'string') {
                    return console.error('Error: Color must be a string')
                }
                this.#color = value
            }
        })
    }
}

class Aircraft {

    #wingspan

    constructor() {

        Object.defineProperty(this, "Wingspan", {
            configurable: true,
            enumerable: true,
            get: function () {
                return this.#wingspan
            },
            set: function (value) {
                if (value && typeof value !== 'number') {
                    return console.error('Error: Wingspan must be a number')
                }
                this.#wingspan = value
            }
        })
    }

    static fly() {
        console.log('whhhhheeeee! I am flying!')
    }
}

class FlyingCar extends Car {
    constructor() {
        super()
        Object.assign(this, Aircraft) // using Aircraft as a kind of mixin
        // Object.preventExtensions(this) // can’t do this as it prevents me from accessing Aircraft properties
    }
}

// put it all together
const flyingCar = new FlyingCar()
flyingCar.Color = 42 // returns an error as expected :-)
flyingCar.Wingspan = 'green' // should fail as wingspan is not a number. But is allowed :(
FlyingCar.fly() // doesn't work.  Seems like static properties are not carried through with Object.assign :(
flyingCar.FavoriteIceCream = 'chocolate' // should fail with object is not extensible, but is allowed :(

The code at the bottom shows what isn’t working but to pull this out:

  • I can copy properties across from Aircraft using Object.assign but I can’t seem to copy across the property definitions
  • Object.assign doesn’t copy across static methods
  • I can’t close off the class using Object.preventExtensions as this prevents me from assigning properties belonging to the Aircraft class

I’d like to have a class-based solution, but would consider a factory (a function that returns a new FlyingCar). I haven’t tried proxies as they just seem overly complex but maybe they could be useful.

I am after a JavaScript-only solution, no TypeScript.

2

Answers


  1. If you want to go the route of mixins, it could look like this. But the problem of using Object.assign is that you assign properties to the FlyingCar class outside of the class itself, which is not very OOP.

    let carMixin = {
      color:"red",
      drive() {
        console.log(`vroom`);
      }
    };
    let planeMixin = {
      wingspan:4,
      fly() {
        console.log(`flying`);
      }
    };
    class FlyingCar {
       constructor(){
         console.log(this.color)
         console.log(this.wingspan)
       }
    }
    Object.assign(FlyingCar.prototype, carMixin);
    Object.assign(FlyingCar.prototype, planeMixin);
    let f = new FlyingCar()
    f.drive()
    f.fly()
    
    Login or Signup to reply.
  2. I’d strongly recommend using composition, not inheritance, to create your FlyingCar class. JavaScript simply does not have multiple inheritance, and attempts to fake it are doomed to fail in various ways, sometimes subtle ways.

    But let’s first look at why your current implementation doesn’t work, and what we could do to make it work following that approach.

    Object.assign copies the current value of an accessor, not the accessor itself, as though you’d done target.prop = source.prop. You can see that here:

    const obj = {
        get random() {
            return Math.random();
        }
    };
    console.log("Using obj directly:");
    console.log(obj.random); // Some random value
    console.log(obj.random); // Another random value
    console.log(obj.random); // Some third random value
    
    const copy = Object.assign({}, obj);
    console.log("Using copy from Object.assign:");
    console.log(copy.random); // The same
    console.log(copy.random); // ...value every...
    console.log(copy.random); // ...time
    .as-console-wrapper {
        max-height: 100% !important;
    }

    So we can’t just use Object.assign for this.

    Separately, to use Aircraft instance properties and fields (both Wingspan and #wingspan are instance-specific), you’ll need an instance of Aircraft — those don’t exist on the Aircraft constructor function or on Aircraft.prototype. We can solve that by having a private field on the FlyingCar instance. Then we can create instance properties for the Aircraft properties and methods and delegate to that Aircraft instance. Similarly, to get the fly static method, we can delegate Aircraft properties and methods to Aircraft from FlyingCar.

    That could look something like this (note that addFacades is just a sketch, not an all-singing, all-dancing implementation [for instance, it doesn’t try to handle inherited properties]):

    "use strict";
    
    function addFacades(target, source) {
        for (const name of Object.getOwnPropertyNames(source)) {
            if (
                typeof source !== "function" ||
                (name !== "prototype" && name !== "name" && name !== "length")
            ) {
                const sourceDescr = Object.getOwnPropertyDescriptor(source, name);
                const targetDescr = {
                    enumerable: sourceDescr.enumerable,
                    get: () => {
                        return source[name];
                    },
                };
                if (sourceDescr.writable || sourceDescr.set) {
                    targetDescr.set = (value) => {
                        source[name] = value;
                    };
                }
                Object.defineProperty(target, name, targetDescr);
            }
        }
    }
    
    class Car {
        #color;
    
        constructor() {
            Object.defineProperty(this, "Color", {
                configurable: true,
                enumerable: true,
                get: function () {
                    return this.#color;
                },
                set: function (value) {
                    if (value && typeof value !== "string") {
                        return console.error("Error: Color must be a string");
                    }
                    this.#color = value;
                },
            });
        }
    }
    
    class Aircraft {
        #wingspan;
    
        constructor() {
            Object.defineProperty(this, "Wingspan", {
                configurable: true,
                enumerable: true,
                get: function () {
                    return this.#wingspan;
                },
                set: function (value) {
                    if (value && typeof value !== "number") {
                        return console.error("Error: Wingspan must be a number");
                    }
                    this.#wingspan = value;
                },
            });
        }
    
        static fly() {
            console.log("whhhhheeeee! I am flying!");
        }
    }
    
    class FlyingCar extends Car {
        #aircraft;
    
        constructor() {
            super();
            this.#aircraft = new Aircraft();
            // Handle instance/prototype properties
            addFacades(this, this.#aircraft);
            Object.preventExtensions(this);
        }
    }
    // Handle statics
    console.log("fly" in Aircraft);
    console.log(Object.hasOwn(Aircraft, "fly"));
    addFacades(FlyingCar, Aircraft);
    
    // put it all together
    const flyingCar = new FlyingCar();
    flyingCar.Color = 42; // returns an error as expected
    flyingCar.Wingspan = "green"; // returns an error as expected
    FlyingCar.fly(); // works
    flyingCar.FavoriteIceCream = "chocolate"; // fails

    Note that in order to get an actual error from assigning to flyingCar.FavoriteIceCream, you need this code to be in strict mode. (Maybe yours is, if it’s in a module. In the above, since it’s not a module, I needed the directive.)

    At this point, FlyingCar inherits from Car and mixes in Aircraft. That is, it’s half inheritance and half composition. That might be reasonable if the class is "mostly" a Car and only partially an Aircraft, but it’s still…assymmetrical. That lack of balance suggests it’s not the best approach.

    Which brings us back to: I’d use composition, not inheritance, and I’d probably lean toward doing it manually rather than using addFacades above. Perhaps something like this:

    "use strict";
    
    class Car {
        #color; // If this should always be a string, it should have a default value
    
        get color() {
            return this.#color;
        }
    
        set color(value) {
            // I removed the `value &&` that used to be on the `if` below, because it
            // shouldn't be there. `0` is a falsy value that isn't a string (as are
            // several others -- `NaN`, `undefined`, `null`, `false`, ...), you don't
            // want to assign those to your `#color` field either.
            if (typeof value !== "string") {
                // (In your real code I assume you `throw` here, but doing it this way
                // is convenient for your example so I suspect that's why you did it).
                return console.error("Error: color must be a string");
            }
            this.#color = value;
        }
    }
    
    class Aircraft {
        #wingspan; // If this should always be a number, it should have default value
    
        get wingspan() {
            return this.#wingspan;
        }
    
        set wingspan(value) {
            // As with `color`, I've removed the `value &&` part of the below. `""` is a
            // falsy value (as are several others that aren't numbers -- `undefined`,
            // `null`, `false`, ...). You don't want to assign those to `#wingspam`.
            if (value && typeof value !== "number") {
                // (In your real code I assume you `throw` here, but doing it this way
                // is convenient for your example so I suspect that's why you did it).
                return console.error("Error: wingspan must be a number");
            }
            this.#wingspan = value;
        }
    
        static fly() {
            console.log("whhhhheeeee! I am flying!");
        }
    }
    
    class FlyingCar {
        #car;
        #aircraft;
    
        constructor() {
            this.#car = new Car();
            this.#aircraft = new Aircraft();
            Object.preventExtensions(this);
        }
    
        get color() {
            return this.#car.color;
        }
    
        set color(value) {
            this.#car.color = value;
        }
    
        get wingspan() {
            return this.#aircraft.wingspan;
        }
    
        set wingspan(value) {
            this.#aircraft.wingspan = value;
        }
    
        static fly() {
            return Aircraft.fly();
        }
    }
    
    // put it all together
    const flyingCar = new FlyingCar();
    flyingCar.color = 42; // returns an error as expected
    flyingCar.wingspan = "green"; // returns an error as expected
    flyingCar.color = "green"; // works
    flyingCar.wingspan = 42; // works
    console.log(`color = ${flyingCar.color}`);
    console.log(`wingspan = ${flyingCar.wingspan}`);
    FlyingCar.fly(); // works
    flyingCar.favoriteIceCream = "chocolate"; // fails
    .as-console-wrapper {
        max-height: 100% !important;
    }

    (In the above, I’ve also used accessor definitions rather than Object.defineProperty to create the accessors for #color and #wingspan, and used standard JavaScript naming conventions [color, rather than Color, and the same for wingspan/Wingspan].)

    But if you wanted to do it more automatically, look for the various mixin helpers that people have built, or build your own (perhaps starting from addFacades).

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search