light-traits
Traits are a simple mechanism for structuring object-oriented programs. They represent reusable and composable building blocks of functionality that factor out the common attributes and behavior of objects.
They are a more robust alternative to mixins and multiple inheritance, because name clashes must be explicitly resolved and composition is commutative and associative (i.e. the order of traits in a composition is irrelevant).
Use traits to share functionality between similar objects without duplicating code or creating complex inheritance chains.
Trait Creation
To create a trait, call the Trait
constructor function exported by this
module, passing it a JavaScript object that specifies the properties of the
trait.
let Trait = require('light-traits').Trait;
let t = Trait({
foo: "foo",
bar: function bar() {
return "Hi!"
},
baz: Trait.required
});
Traits can both provide and require properties. A provided property is a property for which the trait itself provides a value. A required property is a property that the trait needs in order to function correctly but for which it doesn't provide a value.
Required properties must be provided by another trait or by an object with a
trait. Creation of an object with a trait will fail if required properties are
not provided. Specify a required property by setting the value of the property
to Trait.required
.
Object Creation
Create objects with a single trait by calling the trait's create
method. The
method takes a single argument, the object to serve as the new object's
prototype. If no prototype is specified, the new object's prototype will be
Object.prototype
.
let t = Trait({
foo: 'foo',
bar: 2
});
let foo1 = t.create();
let foo2 = t.create({ name: 'Super' });
Trait Composition
Traits are designed to be composed with other traits to create objects with the properties of multiple traits. To compose an object with multiple traits, you first create a composite trait and then use it to create the object. A composite trait is a trait that contains all of the properties of the traits from which it is composed. In the following example, MagnitudeTrait is a composite trait.
let EqualityTrait = Trait({
equal: Trait.required,
notEqual: function notEqual(x) {
return !this.equal(x)
}
});
let ComparisonTrait = Trait({
less: Trait.required,
notEqual: Trait.required,
greater: function greater(x) {
return !this.less(x) && this.notEqual(x)
}
});
let MagnitudeTrait = Trait.compose(EqualityTrait, ComparisonTrait);
Trait Resolution
Composite traits have conflicts when two of the traits in the composition
provide properties with the same name but different values (when compared using
the ===
strict equality operator). In the following example, TC
has a
conflict because T1
and T2
both define a foo
property:
let T1 = Trait({
foo: function () {
// do something
},
bar: 'bar',
t1: 1
});
let T2 = Trait({
foo: function() {
// do something else
},
bar: 'bar',
t2: 2
});
let TC = Trait.compose(T1, T2);
Attempting to create an object from a composite trait with conflicts throws a
remaining conflicting property
exception. To create objects from such traits,
you must resolve the conflict.
You do so by excluding or renaming the conflicting property of one of the traits. Excluding a property removes it from the composition, so the composition only acquires the property from the other trait. Renaming a property gives it a new, non-conflicting name at which it can be accessed.
In both cases, you call the resolve
method on the trait whose property you
want to exclude or rename, passing it an object. Each key in the object is the
name of a conflicting property; each value is either null
to exclude the
property or a string representing the new name of the property.
For example, the conflict in the previous example could be resolved by excluding
the foo
property of the second trait.
let TC = Trait(T1, T2.resolve({ foo: null }));
It could also be resolved by renaming the foo
property of the first trait to
foo2
:
let TC = Trait(T1.resolve({ foo: "foo2" }), T2);
When you resolve a conflict, the same-named property of the other trait (the one that wasn't excluded or renamed) remains available in the composition under its original name.
Constructor Functions
When your code is going to create more than one object with traits, you may want to define a constructor function to create them. To do so, create a composite trait representing the traits the created objects should have, then define a constructor function that creates objects with that trait and whose prototype is the prototype of the constructor:
let PointTrait = Trait.compose(T1, T2, T3);
function Point(options) {
let point = PointTrait.create(Point.prototype);
return point;
}
Property Descriptor Maps
Traits are designed to work with the new object manipulation APIs defined in
ECMAScript-262, Edition
5 (ES5).
Traits are also property descriptor maps that inherit from Trait.prototype
to
expose methods for creating objects and resolving conflicts.
The following trait definition:
let FooTrait = Trait({
foo: "foo",
bar: function bar() {
return "Hi!"
},
baz: Trait.required
});
Creates the following property descriptor map:
{
foo: {
value: 'foo',
enumerable: true,
configurable: true,
writable: true
},
bar: {
value: function b() {
return 'bar'
},
enumerable: true,
configurable: true,
writable: true
},
baz: {
get baz() { throw new Error('Missing required property: `baz`') }
set baz() { throw new Error('Missing required property: `baz`') }
},
__proto__: Trait.prototype
}
Since Traits are also property descriptor maps, they can be used with built-in
Object.*
methods that accept such maps:
Object.create(proto, FooTrait);
Object.defineProperties(myObject, FooTrait);
Note that conflicting and required properties won't cause exceptions to be
thrown when traits are used with the Object.*
methods, since those methods are
not aware of those constraints. However, such exceptions will be thrown when the
property with the conflict or the required but missing property is accessed.
Property descriptor maps can also be used in compositions. This may be useful for defining non-enumerable properties, for example:
let TC = Trait.compose(
Trait({ foo: 'foo' }),
{ bar: { value: 'bar', enumerable: false } }
);
When using property descriptor maps in this way, make sure the map is not the
only argument to Trait.compose
, since in that case it will be interpreted as
an object literal with properties to be defined.