blog

Wooden elephants, Photo by William Warby

ES6 Subclasses and Object.defineProperty

by

(Photo by William Warby)

Object.defineProperty provides a handy way to add properties to JavaScript objects. It’s been around for a while, but with the introduction of class syntax in ES6, it’s not immediately obvious how to use it to add properties to a class, as opposed to an instantiated object.

ES6 (now renamed ES 2015) adds syntactic sugar to support the appearance of traditional class inheritance. Now, rather than using prototypical inheritance directly, it’s possible to write code that looks more familiar when coming from other languages:

class AFruit {
   get color() { return 'red'; }
}
new AFruit().color;
// "red"
// Not at all contrived example of inheritance, overriding, and use of super
class AApple extends AFruit {
   get color() { return [super.color, 'green', 'yellow']; }
}
new AApple().color;
// ["red", "green", "yellow"]

In some cases, such as for data model classes, there might be a large number of properties. It can be helpful to define the property names and related metadata in a single data structure, then iterate over that data structure to create getters and setters.

In contrast, here’s a Backbone.Model class that repetitiously defines a getter and setter for each attribute, which are necessary because the server attribute names don’t match the frontend model property names:

class AContact extends Backbone.Model {
get firstName() { return this.get('first_name'); }
set firstName(value) { this.set('first_name', value); }
get emailConfirmed() { return this.get('email_confirmed'); }
set emailConfirmed(value) { this.set('email_confirmed', value); }
get syncId() { return this.get('sync_id'); }
set syncId(value) { this.set('sync_id', value); }
get active() { return this.get('active'); }
set active(value) { this.set('active', value); }
get extraData() { return this.get('extra_data'); }
set extraData(value) { this.set('extra_data', value); }
get created() { return this.get('created_at'); }
set created(value) { this.set('created_at', value); }
get lastModified() { return this.get('last_modified_at'); }
set lastModified(value) { this.set('last_modified_at', value); }
}

There’s duplicate, boilerplate code here, which creates a potential for bugs.

Object.defineProperty to the rescue

The Object.defineProperty() method defines or modifies a property directly on an object. Most examples of its use will show it being called on a simple object or class instance, like:

let foo = {};
Object.defineProperty(foo, 'bar', {value: 'baz'});
foo.bar;
// "baz"

But it can also be used on a class, which means that all instances of the class will get the property because of prototypical inheritance. However, a key element that might not be immediately obvious is that the first argument to defineProperty must be the class’s prototype, as opposed to the class itself.

class AFoo {}
foo = new AFoo();
foo.bar;
// undefined
Object.defineProperty(AFoo.prototype, 'bar', {value: 'baz'});
foo.bar;
// "baz"

Object.defineProperty in ES6 subclasses

In ES6, a subclass’s prototype is an instance of its super class.

class ASubFoo extends AFoo {}
ASubFoo.prototype;
// AFoo {}
ASubFoo.prototype instanceof AFoo;
// true
ASubFoo.prototype === AFoo;
// false
ASubFoo.prototype.bar;
// "baz"

That means that properties added to the super class will be available on the subclass, but not vice versa, which is presumably what you want.

let subfoo = new ASubFoo();
subfoo.bar;
// "baz"
Object.defineProperty(ASubFoo.prototype, 'subbar', {value: 'subbaz'});
subfoo.subbar;
// "subbaz"
foo.subbar;
// undefined

Coming back to the earlier Backbone.Model example, the property and attribute names can be defined in an iterable data structure. The properties can then be automatically added to the model class, without affecting other classes that also descend from Backbone.Model.

class AContact extends Backbone.Model {}
const properties = [{
        prop: 'firstName',
        attr: 'first_name'
    },
    {
        prop: 'emailConfirmed',
        attr: 'email_confirmed'
    },
    {
        prop: 'syncId',
        attr: 'sync_id'
    },
    {
        prop: 'active',
        attr: 'active'
    },
    {
        prop: 'extraData',
        attr: 'extra_data'
    },
    {
        prop: 'created',
        attr: 'created_at'
    },
    {
        prop: 'lastModified',
        attr: 'last_modified_at'
    },
];
// For each prop name, define a property on the model prototype
// that includes a getter and setter, which call
// Backbone.Model.get() and .set() using the attr name.
for (const propData of properties) {
    Object.defineProperty(AContact.prototype, propData.prop, {
        get: function() {
            return this.get(propData.attr);
        },
        set: function(value) {
            this.set(propData.attr, value);
        },
    });
}
let guy = new AContact({
    first_name: 'Guy'
});
guy.firstName
// "Guy"
// Demonstrate that other Backbone.Model classes don't
// accidentally receive the added properties
let other = new Backbone.Model({
    first_name: 'Other'
});
other.firstName
// undefined

The for loop that defines the properties can be reused for multiple model classes, and so it makes sense to add it to a common base class. In a followup post, I’ll write up a more detailed base Backbone.Model class that goes further, using a similar data structure and Object.defineProperty to also:

  • Offer more control over the properties, e.g. create properties without setters
  • Define default values for attributes
  • Define mappings for parsing date strings and model relationships

+ more