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 caseset
: override the property setter, e.g. to undefine it to prevent changing the property valueenumerable
: iffalse
, the property won’t turn up in afor...of
loop over a model instancedefault
: a default value (or function, which lazily returns a default value) for new model instancesmapping
: the type of server data -> frontend data parsing to apply, e.g. for deserializing a date stringclass
: 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:
- iterate over the array of property definition objects
- define a property in the subclass prototype using the
prop
name - create getters and setters, which call
Backbone.Model.get()
andset()
with either theattr
or theprop
name - default
enumerable
to true - allow the
descriptor
param ofObject.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:
ABaseModel
AContact
andAAddress
model subclasses- example usage of those subclasses.
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)