Protomatter is an object creation and inheritance library for JavaScript, featuring private instance properties and private methods.
- Introduction
- Features
- Installation
- Usage
- Object Privacy Implementation Details
- Environment Support
- Performance Considerations
- Limitations
- Inspirations
Protomatter provides some of the conveniences of classical languages, such as privacy and invoking methods of a "superclass", while utilizing the power of JavaScript's prototypal inheritance. It's purpose is to make working directly with JS prototypes easy and to help you create clean object APIs by encapsulating private state and implementation details. It also enables you to take advantage of JS as a truly class-less language with concatenative inheritance.
Protomatter helps you work with JavaScript's prototypal nature by creating
prototype objects instead of constructors. It returns the prototype with a
create()
method for easily producing new object instances linked to that
prototype. Working directly with the prototype simplifies the organization of
your code, negating the need to access the .prototype
property of some
constructor function. Additionally, any properties you assign to this
inside a
method will be private, innaccessible from outside the object's methods. You can
also mark methods as private when creating a prototype. No more trying to
remember to prefix everything private with underscores!
- All properties assigned to
this
are private by default. - Private methods are only accessible from the object's own methods.
- Easy creation of prototype chains (for prototypal inheritance).
- Easy invocation of overridden methods higher up in the prototype chain.
- Easy use of concatenative (multiple) inheritance with prototype composition.
- Flexible management of public methods through late-binding.
- Extension of objects post-instantiation through mixin support.
- Works in Node.js and browsers.
For Node.js:
npm install protomatter
With Bower:
bower install protomatter.js
Use Protomatter.create()
to set up a new prototype, passing it an object
with the properties you want available to instances created from the prototype.
Add an init
method to set up any instance variables when your prototype
creates an instance. Place any methods that you want to be private under the
key private
. All methods outside private
will be public.
var Modal = Protomatter.create({
init: function(title, body, triggerEl) {
this.title = title;
this.body = body;
this.triggerEl = triggerEl;
this.attachEventListeners();
},
getBody: function() {
return this.body;
},
getTitle: function() {
return this.title;
},
hide: function() {
this.removeBackdrop();
// ...
},
private: {
attachEventListeners: function() {
// No need to bind this.show, as Protomatter will set context correctly.
this.triggerEl.addEventListener('click', this.show);
},
removeBackdrop: function() {
// ...
}
},
show: function() {
// ...
}
});
You can also pass a function instead of an object. The function should return an object with the properties to add to the new prototype. This can be convenient for encapsulating your prototype's properties.
var Modal = Protomatter.create(function() {
return {
init: function() {
// ...
},
// ...
};
});
Protomatter adds a create()
method to your prototypes that you can call to
create instances. It invokes the init
method on the new instance if one is
available. Any arguments passed to create()
will be passed along to init()
if it was provided. Note that any instance properties you create in init
or
any other prototype methods are private and cannot be accessed outside of the
methods from the prototype. The same goes for private methods.
var contactModal = Modal.create(
'Contact Us',
'[email protected]',
document.getElementById('contact-modal-trigger')
);
// State and private methods are inaccessible from the outside:
console.log(contactModal.title); // undefined
console.log(contactModal.body); // undefined
console.log(contactModal.triggerEl); // undefined
console.log(contactModal.attachEventListeners); // undefined
console.log(contactModal.removeBackdrop); // undefined
// Public methods are accessible and can access state:
console.log(contactModal.getBody()); // '[email protected]'
console.log(contactModal.getTitle()); // 'Contact Us'
Public methods on an instance object are able to access private state because
behind the scenes Protomatter sets a second, hidden object that holds the state
as the method context. Though this is the default behavior, you can still
override the context of a method with .call()
and .apply()
:
var Proto = Protomatter.create({
init: function(foo) {
this.foo = foo;
},
getFoo: function() {
return this.foo;
}
});
var instance = Proto.create('foo');
console.log(instance.getFoo()); // 'foo'
var newContext = {foo: 'bar'};
console.log(instance.getFoo.call(newContext)); // 'bar'
As a convenience, Protomatter will switch the context of a method invocation
back to its instance if it's invoked with the global object or undefined as the
context. This is particularly nice in that you no longer need to manually
bind()
methods you pass as callbacks.
var Proto = Protomatter.create({
handleClick: function() {
// ...
}
});
var instance = Proto.create();
// `this` in handleClick will be instance when the button is clicked.
document.querySelector('#a-button')
.addEventListener('click', instance.handleClick);
You can create a prototype linked to an existing "super prototype" by calling
its extend()
method. Protomatter will set it as the prototype of your new
prototype, creating a prototype chain.
var SlidingModal = Modal.extend({
init: function(title, body, triggerEl, slideClass) {
this.slideClass = slideClass;
this.callSuper('init', title, body, triggerEl);
},
private: {
removeSlideClass: function() {
// ...
}
},
show: function() {
this.removeSlideClass();
this.callSuper('show');
}
});
You can also create a Protomatter prototype linked to a prototype object that
wasn't created with Protomatter. Simply pass the prototype object in the options
argument to Protomatter.create()
under the key superProto
:
var obj = {foo: 'bar'};
var Proto = Protomatter.create({baz: 'qux'}, {superProto: obj});
You may have noticed in the previous example calls to this.callSuper()
.
When you create a prototype that inherits from another prototype, Protomatter
adds a callSuper()
method to the new prototype. If the new prototype
defines a method by the same name, it can invoke the super prototype's version
of the method by passing the method name and any arguments to callSuper()
.
While Protomatter makes it easy to work with JavaScript prototypes in a classical manner, you have the power in JavaScript to create maintainable code without the restrictions of rigid class structures. In addition to the prototype chain, JavaScript allows objects to be extended with new properties after instantiation. This gives us the ability to compose an object from separate objects, dynamically extending it with new capabilities as needed by copying the properties from another object. This process is sometimes called concatenative inheritance, and it allows for multiple inheritance in JS.
Protomatter supports concatenative inheritance by enabling you to compose
multiple objects/prototypes together using the Protomatter.compose()
method.
It takes a variable number of prototypes as arguments and returns a new
prototype with the properties from all the passed objects copied to it. This
makes it easy to componentize groups of functionality into multiple prototypes
and combine them in various ways.
When you compose prototypes, the resulting prototype's init()
method will
invoke the init()
methods provided by all composed prototypes, passing along
any arguments. Since all init methods get the same arguments, it's best to pass
an object literal with named arguments instead of relying on positional
arguments.
var Commentable = Protomatter.create({
addComment: function() {
this.saveComment();
},
init: function(options) {
this.comments = options.comments;
},
numComments: function() {
return this.comments.length;
},
private: {
saveComment: function() {
// ...
}
}
});
var Likeable = Protomatter.create({
init: function(options) {
this.liked = options.liked;
},
isLiked: function() {
return this.liked;
},
like: function() {
this.saveLike();
},
private: {
saveLike: function() {
// ...
}
}
});
Post = Protomatter.compose({
getText: function() {
return this.text;
},
getTitle: function() {
return this.title;
},
init: function(options) {
this.title = options.title;
this.text = options.text;
}
}, Commentable, Likeable);
post = Post.create({
comments: [],
liked: false,
text: '...',
title: 'Prototypal OO'
});
console.log(post.numComments()); // 0
console.log(post.isLiked()); // false
console.log(post.getText()); // '...'
console.log(post.getTitle()); // 'Prototypal OO'
Learn more about concatenative inheritance in Eric Elliott's post on three kinds of prototypal OO.
In addition to prototype composition, Protomatter allows you to mix in
properties to prototype instances after creation. It adds a mixIn()
method to
every instance. This is useful if you want to extend one specific instance
instead of composing a new prototype.
var Proto = Protomatter.create({});
var instance = Proto.create();
var mixin = {
getThing: function() {
return this.thing;
},
setThing: function(thing) {
this.thing = thing;
}
};
instance.mixIn(mixin);
instance.setThing('foo');
console.log(instance.getThing()); // foo
Note: You can disable the ability to mix in properties to an instance by
passing the allowMixins
option as false to Protomatter.create()
.
var Proto = Protomatter.create({}, {allowMixins: false});
Protomatter prevents access to object properties except for public methods and
properties added to prototypes. But sometimes you may want to expose a public
property on an instance object. You can do this by setting the property on
this.public
instead of this
:
var Person = Protomatter.create({
init: function(firstName, lastName) {
this.firstName = firstName;
this.lastName = lastName;
// fullName will be publicly accessible.
this.public.fullName = firstName + ' ' + lastName;
}
});
var tom = Person.create('Tom', 'Johnson');
console.log(tom.firstName); // undefined
console.log(tom.lastName); // undefined
console.log(tom.fullName); // 'Tom Johnson'
If you have to work with code implemented with constructors, say a third-party
library you can't change, you can convert it to a Protomatter prototype with
Protomatter.convert()
. It takes a constructor function and returns a prototype
with the properties of the constructor's .prototype
object, and uses the
constructor itself as the new prototype's init()
method. If the constructor's
prototype inherits from another prototype, Protomatter will have the new
prototype inherit from it as well.
var SuperProto = {
// ...
};
var Constructor = function(foo, bar) {
this.foo = foo;
this.bar = bar;
};
Constructor.prototype = Object.create(SuperProto);
Constructor.prototype.getBar = function() {
return this.bar;
};
Constructor.prototype.getFoo = function() {
return this.foo;
};
var ConvertedProto = Protomatter.convert(Constructor),
instance = ConvertedProto.create('baz', 'qux');
console.log(instance.getBar()); // 'qux'
console.log(instance.getFoo()); // 'baz'
console.log(Object.getPrototypeOf(ConvertedProto) === SuperProto); // true
If you're interested in the technical details behind Protomatter, check out this article on Protomatter's object privacy implementation.
Protomatter is built to work in an ECMAScript 5 environment. If you want to use it in a pre-ES5 environment (e.g. Internet Explorer 8 and below), you will need to polyfill the following ES5 features:
Function.prototype.bind()
Object.create()
(Protomatter only uses the first argument)Object.getPrototypeOf()
I recommend using the es5-shim.
Protomatter's strengths of privacy and working directly with prototypes come
with some performance tradeoffs. Some modern JS engines optimize constructor
functions to be faster than Object.create()
and other means
of object creation. V8's hidden classes are an example.
That said, object creation performance is rarely a matter of concern. Though perf tests may make the loss in performance seem dramatic, the objective speed is still very fast. It will likely have no noticeable impact on the perceived performance of your app unless you're continuously creating huge numbers of objects (in which case you should probably switch to an object pool to avoid garbage collection pauses).
The key point to take away is you don't need to let performance concerns dictate your object creation strategy upfront. Instead, you should profile to find performance bottlenecks and only then switch to faster but less convenient methods of object creation when the performance gain is worth it.
Protomatter's ability to hide private properties and methods comes at the cost of a few limitations that should be taken into consideration when working with it.
Objects created from a Protomatter prototype can have new properties added to them just like any other JS object. The caveat to keep in mind is that if you add a new method post-creation, it won't be able to access the object's private state.
var Proto = Protomatter.create({
init: function() {
this.foo = 'bar';
}
});
var instance = Proto.create();
instance.getFoo = function() {
return this.foo;
};
console.log(instance.getFoo()); // undefined
The solution is to add the new method to the object using its mixIn()
method.
JavaScript's instanceof
operator takes a constructor function as its righthand
argument. Since Protomatter's prototypes are objects, you can't test if a
particular instance inherits from a prototype using instanceof
. But you can
use Object.prototype.isPrototypeOf()
to get the answer:
var Proto = Protomatter.create({});
var instance = Proto.create();
console.log(Proto.isPrototypeOf(instance)); // true
Note that isPrototypeOf()
is an ES5 method, so you'll need to polyfill it to
use it in older JS environments (see the environment support section).
Protomatter employs late binding when executing public methods. Therefore, you can easily replace the implementation of a public method on a prototype after instances have been created.
var Proto = Protomatter.create({
init: function() {
this.foo = 'bar';
},
getFoo: function() {
return this.foo;
}
});
var instance = Proto.create();
console.log(instance.getFoo()); // 'bar'
Proto.getFoo = function() {
return 'Foo: ' + this.foo;
};
console.log(instance.getFoo()); // 'Foo: bar'
New methods added to prototypes after instances have been created, however, will be unable to access the private state of an instance.
Proto.getFoo2 = function() {
return this.foo;
};
console.log(instance.getFoo2()); // undefined
So make sure to have all public methods added to your prototypes at definition time.
Inspiration for Protomatter came from a variety of JS thinkers and projects that advocate the power JS's prototypal nature has over older, class-based thinking. These projects and resources were particularly influential:
- JS Objects: De"construct"ion by Kyle Simpson
- Stampit: Create objects from reusable, composable behaviors. by Eric Elliott.
- You Don't Know JS: this & Object Prototypes by Kyle Simpson