blog

Elephant backs, Photo by Alan Jones

Configuring Backbone.Model Properties With Less Code

by

My previous post demonstrated the use of Object.defineProperty to programmatically add a list of properties to an ES6 class, such as a Backbone.Model subclass, reducing the amount of boilerplate code necessary. The example in that post added simple getters and setters, but it’s possible to go further for Backbone.Model properties, also configuring in a single data structure functionality like:

  • Options for properties, such as disabled setters
  • Default values for properties
  • Mappings for parsing date strings and model relationships

This technique uses a common base class from which all other model classes descend. The model subclasses (e.g. a user model) provide a list of property definitions, which centralize various aspects of those properties. The base class has configuration methods which iterate over the property definition objects.

In the particular examples below, the base configuration methods handle attribute to property name mapping, default values, and server data parsing, but this technique can be applied to other configurable property-related tasks, such as marking some properties blacklisted or viewable only by admins.

Property configuration

Let’s start by looking at the property definition data structure, which is an array of dictionary objects:

propConfig = [
   // Simple prop
   {prop: 'email'},
   // Prop with different server attribute name
   {prop: 'firstName', attr: 'first_name'},
   // Un-modifiable prop
   {prop: 'emailConfirmed', set: undefined},
   // Un-enumerable prop
   {prop: 'syncId', enumerable: false},
   // Primitive default value
   {prop: 'active', default: true},
   // Object default value
   {prop: 'extraData', default: function() { return {}; }},
   // Date default value
   {prop: 'createdDate', default: function() { return new Date(); }},
   // Date string to date object parsing
   {prop: 'lastModified', mapping: 'date'},
   // Relationship parsing
   {prop: 'address', mapping: 'relation', class: AAddress},
];

The one required value is prop, which determines the property name. Other values are optional, and add functionality to the model’s use of the property.

  • attr: specify a different server attribute name, e.g. because the server uses underscores but the frontend uses camel case
  • set: override the property setter, e.g. to undefine it to prevent changing the property value
  • enumerable: if false, the property won’t turn up in a for...of loop over a model instance
  • default: a default value (or function, which lazily returns a default value) for new model instances
  • mapping: the type of server data -> frontend data parsing to apply, e.g. for deserializing a date string
  • class: for 'relation' mappings, the Backbone model class to construct for the related data

That’s a fair bit of diverse functionality packed into a dense configuration array. Subclasses of the base model class provide their own particular array of property definitions, and require no additional code.

Creating the properties

We need a common base class that descends from Backbone.Model to make use of this array of property definition objects. We’ll fill in the class contents as we go along.

class ABaseModel extends Backbone.Model {
   // ...
}

(For an interesting read on ES6 subclassing and Backbone, see Backbone and ES6 Classes Revisited).

The first step to using the array of properties is to actually create them as properties, on a model class prototype.

class ABaseModel extends Backbone.Model {
   static get propConfig() {
      // subclasses override this to return their
      // own array of property definition objects
      return [];
   }
   static configureProps() {
      for (const propData of this.propConfig) {
         const prop = propData.prop;
         const attr = propData.attr || propData.prop;
         const descriptor = propData;
         Object.defineProperty(this.prototype, prop, Object.assign({
            get: function() {
               return this.get(attr);
            },
            set: function(value) {
               this.set(attr, value);
            },
            enumerable: true,
         }, descriptor));
      }
   }
   // ...
}

When configureProps() is called on a subclass of ABaseModel, it will:

  1. iterate over the array of property definition objects
  2. define a property in the subclass prototype using the prop name
  3. create getters and setters, which call Backbone.Model.get() and set() with either the attr or the prop name
  4. default enumerable to true
  5. allow the descriptor param of Object.defineProperty() to be overridden by values in the property definition object, e.g. {enumerable: false}

Here’s a simple example of a contact subclass defining a single fullName property:

class AContact extends ABaseModel {
   static get propConfig() {
      return super.propConfig.concat([{prop: 'fullName', attr: 'full_name'}]);
   }
}
AContact.configureProps();

The call to AContact.configureProps() actually creates the properties. The overriden propConfig getter is calling super, in case its super class has defined some shared properties that should be present in all model classes.

After constructing an instance of a contact, we can see the fullName property at work:

let contact = new AContact({full_name: 'Bob Jimbob'});
contact.fullName;
// "Bob Jimbob"

Default values

Backbone.Model.defaults returns an object with attribute names mapping to default values for new model instances. Typically, model subclasses will have a method like:

class AContact extends Backbone.Model {
   defaults() {
      return {
         full_name: 'Default name',
      };
   }
}
new AContact({}).get('full_name');
// "Default name"

But we’ve defined the default values alongside the properties, centralizing their configuration. The base model code to enable use of the default values in propConfig is fairly straightforward:

class ABaseModel extends Backbone.Model {
   // ...
   get defaults() {
      const defaults = {};
      this.constructor.propConfig.forEach((propData) => {
         const attr = propData.attr || propData.prop;
         let def = propData.default;
         if ('function' === typeof def) {
            def = propData.default.call(this)
         }
         defaults[attr] = def;
      });
      return defaults;
   }
}

It generates an object mapping attribute name to default value, using default values in the array of property definition objects.

If those defaults are actually functions, it’ll call the functions with the model instance as context, and the default value for the new instance will be the return value of the default function. Providing a function option is important because default values that are objects should never be shared between model instances (otherwise, updating one model instance would make a change in all others). Indeed, a sanity check in the above method that throws an error if 'object' === typeof def might be wise.

Mapping and parsing

The third feature of our common base class is parsing data returned by the server. Model instance properties aren’t always the limited set of primitives and simple objects that can be directly represented in JSON (null, string, number, boolean, array, and object).

For example, date properties in a model class should be Date instances, not string representations of a date. If contact.createdDate returned a ISO 8601 string, every time the creation date was displayed in the GUI, it would first need to be deserialized before UI formatting. Instead, that deserialization should be done when server data is loaded into a model instance.

So we need some code to translate a server response like {createdDate: '2015-01-01'} to contact.createdDate = new Date('2015-01-01'). The parse method below does just that, if a property’s definition object in propConfig includes {mapping: 'date'}.

class ABaseModel extends Backbone.Model {
   // ...
   /**
   * Returns propConfig array, filtered by properties that have a mapping value.
   * @returns {array}
   */
   get mappings() {
      return this.constructor.propConfig.filter((propData) => {
         return !!propData.mapping;
      });
   }
   /**
   * Override Backbone.Model.parse to map API values to model values based on
   * propConfig. parse is called automatically whenever a model's data is
   * returned by the server, in fetch and save.
   */
   parse(response) {
      for (const propData of this.mappings) {
         const attr = propData.attr || propData.prop;
         const mapping = propData.mapping;
         const val = response[attr];
         if (null !== val && undefined !== val) {
            switch (mapping) {
               case 'date':
                  response[attr] = new Date(val);
                  break;
               default:
                  throw new Error(Unknown mapping type ${mapping} for prop ${attr});
            }
         }
      }
      return response;
   }
}

Another type of mapping we'd like to support is automatically creating related model instances. For example, if the server responds with contact data that includes a related address:

{
   first_name: 'Fred',
   home: {
      address: '55 Lux Ave',
   },
}

then, after parsing, the following should be true:

fred.home instanceof AAddress
// true

so that Fred’s home address is a model instance, with defaults, its own parsing, etc.

To support this parsing, we simply add another case to the switch clause in parse:

...
case 'relation':
   response[attr] = new propData.class(val, {parse: true});
   break;
...

Recall from the original propConfig example at the top of this post that the class value in a property definition object is a model class, allowing a new instance of that model to be instantiated during parsing.

In fact, let’s go one step further, and support arrays of related models, stored inside a Backbone.Collection, by expanding our case 'relation' statement to check for Array type values:

...
case 'relation':
   if (val instanceof Array) {
      const collection = new Backbone.Collection();
      collection.model = propData.class;
      // add() handles an array of attributes for multiple models
      collection.add(val, {parse: true});
      response[attr] = collection;
   } else {
      response[attr] = new propData.class(val, {parse: true});
   }
   break;
...

Now, if Fred has multiple office addresses, they’ll all get parsed from this:

{
   first_name: 'Fred',
   offices: [
      {
         address: '1 State St',
         city: 'NYC',
         state: 'NY',
      },
      {
         address: '50 Birch St',
         city: 'Portland',
         state: 'OR',
      },
   ],
}

to this:

fred.offices instanceof Backbone.Collection
// true
fred.offices.first() instanceof AAddress
//true

Full code sample

A full sample of this code is available as a gist, defining:

It demonstrates that, by moving common property configuration and handling logic into a base model class, the many concrete model subclasses can be quite simple and compact. In a single line of code, you can create a new property, with getters, setters, defaults, deserialization support, and many other potential features.

(Photo by Alan Jones)

+ more