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);
notEqual equal EqualityTrait greater notEqual less ComparisonTrait greater less notEqual equal MagnitudeTrait

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.