Intuitive mixins for ES6 classes
Multiple Inheritance is like a parachute. You don't often need it, but when you do, you really need it. Grady Booch
mics (pronounce: mix) is a library that makes multiple inheritance in Javascript a breeze. Inspired by the excellent blog post "Real" Mixins with Javascript Classes by Justin Fagnani, mics tries to build a minimal library around the concept of using class expressions (factories) as mixins. mics extends the concepts presented in the blog post by making the mixins first-class citizens that can be directly used to instantiate objects and can be mixed in with other mixins instead of just with classes.
npm install --save mics
- mics.umd.js (universal module works in browser and node)
- mics.min.js (minified version of universal module file)
import { mix, is, like } from 'mics'
var mix = require('mics').mix
var is = require('mics').is
var like = require('mics').like
define(['mics'], function(mics){
var mix = mics.mix
var is = mics.is
var like = mics.like
});
<script src="https://cdn.rawgit.com/download/mics/0.6.1/dist/mics.min.js"></script>
<script>
var mix = mics.mix
var is = mics.is
var like = mics.like
</script>
Mixins are like classes on steroids. They look and feel a lot like ES6 classes, but they have some additional capabilities that ES6 classes do not have:
- They can 'extend' from multiple other mixins including (at most one) ES6 class
- They have an explicit
interface
which can be inspected and tested at runtime - They have an ES6
class
that is used to create instances - They have a
mixin
function that mixes in their class body into another type. - They can be invoked without
new
to create new instances
mixin: An ES5 constructor function that has properties
mixin
,class
andinterface
.
You create mixins with the mix
function.
mix
accepts an optional superclass as the first argument, then a bunch of mixins
and an optional class factory as the last argument and returns a mixin.
Mostly, you will be using mix
with a factory to create mixins, like this:
import { mix, is, like } from 'mics'
var Looker = mix(superclass => class Looker extends superclass {
constructor() {
super()
console.info('A looker is born!')
}
look() {
console.info('Looking good!')
}
})
typeof Looker // 'function'
typeof Looker.mixin // 'function'
typeof Looker.class // 'function'
typeof Looker.interface // 'object'
Notice that the argument to mix
is an arrow function that accepts a superclass and
returns a class that extends the given superclass. The body of the mixin is defined in
the returned class. We call this a class factory.
Class factory: An arrow function that accepts a
superclass
and returns aclass extends superclass
.
The mix
function creates a mixing function based on the given mixins and the class
factory and invokes it with the given superclass to create the ES6 class backing the mixin.
It then creates an ES5 constructor function that uses the ES6 class to create and return
new instances of the mixin. Finally it constructs the mixin's interface from the class
prototype and attaches the mixin
function, the class
and the interface
to the ES5
constructor function, creating what in the context of mics we call a mixin.
We can directly use the created mixin to create instances, because it is just a constructor function:
var looker = new Looker() // > A looker is born!
looker.look() // > Looking good!
looker instanceof Looker // true
And because it's an ES5 constructor function, we are allowed to invoke it without new
:
var looker = Looker() // > A looker is born!
looker.look() // > Looking good!
ES6 made newless invocation of constructors throw an error for ES6 classes, because in ES5 it was often a cause for bugs when programmers forgot
new
with constructors that assumednew
was used. However I (with many others) believe that not usingnew
is actually better for writing maintainable code. So mics makes sure that it's constructors work whether you usenew
on them or not, because the backing ES6 class is always invoked withnew
as it should be. Whether you want to writenew
or not in your code is up to you.
Let us define mixins Walker
and Talker
to supplement our Looker
:
var Walker = mix(superclass => class Walker extends superclass {
walk() {
console.info('Step, step, step')
}
})
var Talker = mix(superclass => class Talker extends superclass{
talk(){
console.info('Blah, blah, blah')
}
})
Now that we have a bunch of mixins, we can start to use them to achieve multiple inheritance:
var Duck = mix(Looker, Walker, Talker, superclass => class Duck extends superclass {
talk() {
var org = super.talk()
console.info('Quack, quack, quack (Duckian for "' + org + '")')
}
})
var donald = Duck()
donald.talk() // > Quack, quack, quack (Duckian for "Blah, blah, blah")
As you can see, we can override methods and use super
to call the superclass method,
just like we can with normal ES6 classes.
instanceof
works for mixin instances like it does for ES6 classes. But, like ES6
classes, it does not support multiple inheritance. In the example above, Looker
is effectively the superclass for Duck
. Walker
and Talker
are mixed into Duck
by dynamically creating new classes and injecting them into the inheritance chain
between Looker
and Duck
. Because these are new classes, instances of them are
not recognized by instanceof
as instances of Walker
and Talker
.
Fortunately, mics gives us an is
function, which does understand multiple inheritance.
Tests whether subject
is-a type
or extends from type
.
The first parameter to is
defines the subject to test. This can be an instance or
a type. The second parameter is either a type (constructor function, ES6 class or mixin)
or a type string.
duck instanceof Duck // true
duck instanceof Looker // true, but:
duck instanceof Walker // false! mix created a *new class* based on the factory
// `is` to the rescue!
is(duck, Walker) // true
// we can also test the type
is(Duck, Walker) // true
is(Talker, Walker) // false
Often, we don't really care whether the object is a certain type, we just want to know
whether we can treat it like a certain type. Use like(subject, type)
to test whether
a subject adheres to the same interface as is defined by type
:
var viewer = { // create an object with the
look(){} // same interface as Looker
}
is(viewer, Looker) // false, but
like(viewer, Looker) // true
A good example of how this might be useful can be found in the new ES6 feature Promises.
Here we have the concept of a 'thenable'. This is any object that has a then
method on
it. Methods in the Promise API often accept thenables instead of promise instances. Have
a look at Promise.resolve
for example:
Promise.resolve(value) Returns a Promise object that is resolved with the given value. If the value is a thenable (i.e. has a
then
method), the returned promise will "follow" that thenable, adopting its eventual state; otherwise the returned promise will be fulfilled with the value. mdn
Using mix
to define an interface and like
to test for it, we can very naturally
express the concept of a thenable from the Promise spec in code:
/** Defines a Thenable */
var Thenable = mix(superclass => class Thenable extends superclass {
then() {}
})
/** Some mixin which can be treated as a Thenable */
var MyPromise = mix(superclass => class MyPromise extends superclass {
then(resolve, reject) {
resolve('Hello, World!')
}
}
// We can check whether the class is thenable using like
like(MyPromise, Thenable) // true
// we can also check instances
var promise = new MyPromise()
like(promise, Thenable) // true
// Ok, that means we can use Promise.resolve!
Promise.resolve(promise).then((result) => {
console.info(result) // > 'Hello, World!'
})
The default constructor returned from mix
is a one-liner that invokes the ES6
class with new
. But there could be reasons to use a different function instead.
mix
allows you to supply a custom constructor to be used instead. You do this
by providing a static constructor
in the class body:
var Custom = mix(superclass => class Custom extends superclass{
static constructor(...args){
console.info('Custom constructor called!')
return new this(...args)
}
})
var test = Custom() // > 'Custom constructor called!'
is(test, Custom) // true
As a bonus, you can use is()
to do some simple type tests by passing a
string for the type:
class X {}
var factory = superclass => class Y extends superclass {}
var Y = mix(factory)
var Z = mix(X, Y)
is(X, 'function') // true
is(X, 'class') // true
is(X, 'mixin') // false
is(X, 'factory') // false
is(factory, 'function') // true
is(factory, 'class') // false
is(factory, 'mixin') // false
is(factory, 'factory') // true
is(Y, 'function') // true
is(Y, 'class') // false
is(Y, 'mixin') // true
is(Y, 'factory') // false
is(Z, 'function') // true
is(Z, 'class') // false
is(Z, 'mixin') // true
is(Z, 'factory') // false
Supported type strings: "class"
, "mixin"
, "factory"
, and any type strings
that can be passed to typeof
.
- class: x is a (possibly Babel-transpiled) ES6 class
- mixin: x is a mixin that is the result of calling
mix
- factory: x is a class factory function
Add an issue in this project's issue tracker to let me know of any problems you find, or questions you may have.
Credits go to Justin Fagnani for his excellent blog post "Real" Mixins with JavaScript Classes and the accompanying library mixwith.js.
Many thanks to Marco Alka for his contributions to this project.
Copyright 2017 by Stijn de Witt and contributors. Some rights reserved.
Licensed under the Creative Commons Attribution 4.0 International (CC-BY-4.0) Open Source license.