If you’re familiar with Dojo 1, you’re probably familiar with declare
. Declare provides a flexible but controlled way to handle inheritance in JavaScript. It builds on JavaScript’s prototypal inheritance with OO (Object Oriented) principles and provides single and multiple inheritance. This enables developers to create flexible components and powerful mixins.
As we started working on Dojo 2, we knew that a class system was going to be an important foundational technology.
TypeScript and ES6 enter the scene
Dojo 1.0 was released in November 2007 and declare
had a major overhaul in 2009 with Dojo 1.4. In internet terms, that is ancient and the world has moved on.
With Dojo 1.6 in 2010, we started introducing composition as a pattern, first with dojo/store, and later with dgrid. The idea was ahead of its time and people mostly responded with confused looks.
ES6/ES2015 was ratified in June of 2015 and introduced some significant changes. Also during that time, we had increasingly been using TypeScript on our projects, and were seeing significant advantages.
The combination of TypeScript and ES6 not only introduced classes and a significant amount of the Object Oriented (OO) style inheritance that declare
had been created to provide, but also the ability to do compile time type checking on these interfaces. TypeScript allowed access to the syntactical features of ES6 and a few other new language features, but could emit code that worked in the existing ES5 environments. Because we felt these features were a significant boon to producing maintainable code, the choice was made to develop Dojo 2 using TypeScript.
If you’re not familiar with TypeScript you can read more about it here.
The Dilemma
The choice to move to TypeScript came with many benefits, but it also presented a dilemma. ES6 and TypeScript do not support multiple inheritance or mixins as they existed in declare
. These powerful patterns were used in Dojo, Dijit, dstore, and dgrid. Additionally, while OO programming is a useful and widespread paradigm, it is not without its flaws. The seemingly inevitable trend, despite the best intentions, is for class hierarchies to become more and more complex, and for stronger coupling to creep into class relationships.
So while ES6 and TypeScript provided the class
system, we were frustrated that TC39 had removed the original Harmony proposal for classes which provided for mixins or traits, saying that they would revisit it at some point in the future. Currently, a year on from the ratification of ES6, there is still no formal proposal for these features.
Composition with Compose
After looking at many options, the decision was made to move away from the ES6 class system, and create our own library to meet the needs of Dojo 2. dojo/compose
is the result of that effort. Compose rejects the traditional OO notion of class hierarchies and takes a more functional approach. Instead, code reuse is enabled with a small API that provides the ability to create and combine, or compose, immutable factories or ‘classes’. Compose also fully utilizes TypeScript’s static type system to help ensure the correctness of your code.
The Compose API is built around a few simple but powerful methods that can be used to create factories or classes.
create
create
, also usable by simply invoking the compose
function itself, is the base of the compose API and provides the ability to create a new compose factory.
import compose from 'dojo-compose/compose';
interface Foo {
foo: Function,
bar: string,
qat: number
}
interface FooOptions {
foo?: Function,
bar?: string,
qat?: number
}
function fooInit(instance: Foo, options?: FooOptions) {
if (options) {
for (let key in options) {
instance[key] = options[key]
}
}
}
const fooFactory = compose({
foo: function () {
console.log('foo');
},
bar: 'bar',
qat: 1
}, fooInit);
const foo1 = fooFactory();
const foo2 = fooFactory({
bar: 'baz'
});
foo1.foo(); // Logs 'foo'
console.log(foo1.bar); // Logs 'bar'
console.log(foo2.bar); // Logs 'baz'
extend
, overlay
, static
, aspect
, and mixin
These functions provide the ability to take an existing compose factory or factories, and meaningfully combine them with each other, ES6 classes, or objects to produce new factories. Compose embraces immutability as a means to make it easier to reason about the state of your program, so none of the API modifies existing factories, and in fact they are frozen upon creation. The code below provides a quick demonstration of these methods in action, but more detailed examples and explanation can be found in the readme
import compose from 'dojo-compose/compose';
interface Foo {
foo: Function
}
interface FooOptions {
foo: Function
}
function fooInit(instance: Foo, options?: FooOptions) {
if (options) {
for (let key in options) {
instance[key] = options[key]
}
}
}
const fooFactory = compose({
foo: function () {
console.log('foo');
}
}, fooInit);
const foo1 = fooFactory();
const foo2 = fooFactory({
foo() {
console.log('new foo');
}
});
foo1.foo(); // Logs 'foo'
foo2.foo(); // Logs 'new foo'
// Extending an existing factory
const fooBarFactory = fooFactory.extend({
bar: 1
});
let foobar = fooBarFactory();
const bazFactory = compose.create({
baz: 'baz'
}, function(instance: { baz: string }) {
instance.baz = 'initialized';
});
// Mixin an existing factory, chaining initializer functions
const fooBarBazFactory = fooBarFactory.mixin(bazFactory);
const fooBarBaz = fooBarBazFactory();
console.log(fooBarBaz.baz); // logs 'initialized'
// Overlay additional properties onto an existing factory without changing the type
const myFooFactory = fooFactory.overlay(function (proto) {
proto.foo = 'qat';
});
const myFoo = myFooFactory();
console.log(myFoo.foo); // logs "qat"
// Add static properties to the factory itself
const staticFoo = fooFactory.static({
doFoo(): string {
return 'foo';
}
});
console.log(staticFoo.doFoo()); // logs 'foo'
A different kind of composition
The name compose comes with certain connotations, since traditionally the use of composition over inheritance refers to the pattern of delegating to objects that are properties of a class or instance rather than using inheritance to share functionality. This connotation is intentional. While the API is taking factories, or ‘classes’, and ‘extending’ them with additional functionality, or ‘mixing’ them into each other, it does so in a way that is decidedly unlike traditional inheritance. The new class has no reference to the old, and there is no class hierarchy. Rather than calling super
, a class must either use traditional composition to leverage another class’ functionality, or explicitly aspect the desired functionality. While this is a bit of a departure from the type of inheritance some developers may be used to, we believe that it ultimately leads to code that is easier to write, read, and maintain.
Immutability
One of the patterns that declare
allowed, but we realized was a source of errors and enabled developers to shoot themselves (and others) in the foot was the use of mutable classes. The challenge with mutable classes is allowing downstream code to make changes (sometimes unintentionally) in upstream code, leading to unpredictable behavior and a highly coupled codebase.
With Compose, we do our best to support a pattern where any mutation to a class creates a new class. This means that the upstream code that depends on that class gets what it expects, helping reduce regressions and confusion.
import compose from 'dojo-compose/compose';
const createFoo = compose({
foo: 'bar'
});
const createFooExtended = createFoo.extend({
bar: 1
});
console.log(createFoo === createFooExtended); // logs false
Factories
While declare
used constructor functions, and ES6 classes are essentially syntactic sugar for constructor functions, we debated if that made the most sense. Eric Elliott pointed out to us that constructor functions are actually the third most common way of creating instances in JavaScript:
- The object literal (e.g.
const foo = { foo: 'bar' };
) - DOM Factories (e.g.
const node = document.createElement('div');
) - Constructor Functions (e.g.
const p = new Promise();
)
Compose uses initializers and a “decomposed” initialization functionality, where each initializer operates on the instance as a parameter, much like a car moving down the factory assembly line. We find factories to be a more semantically meaningful way of interacting with object instantiation. Factories also hide the details of the initializer’s implementation from the consumer. The new
keyword forces a new context to be created, but a factory function allows the initializer to be bound to any context. This enables the use of patterns such as object pools, and allows more functionality to be changed without requiring corresponding changes in downstream code.
Getting started with compose today
Compose is currently in beta and should not be used in production yet, but the API is not expected to undergo significant changes at this point. The easiest way to get started with compose is to install it via npm
:
$ npm install dojo-compose
Alternatively the dojo/compose
repository can be cloned and compose can be built locally using Grunt. If you’re using TypeScript or ES6 modules, once you’ve obtained a built version of compose you can import it and get started:
import compose from 'dojo-compose/compose';
const createFoo = compose({
foo: 'foo'
}, (instance, options) => {
/* do some initialization */
});
const foo = createFoo();
Learning More
If you’re interested in compose or the advantages that developing with TypeScript and ES6 can bring and want to learn more, we provide an in depth ES6 and Typescript fundamentals workshop. This workshop is aimed to get you up to speed on the most important features of ES6 and TypeScript in a short amount of time. To register, check out our workshop schedule.
You can also contact us directly to discuss how we can help your organization learn more about ES6 and TypeScript.