The TypeScript handbook provides a mixin pattern but it requires a bunch of boilerplate, doesn't work for getters/setters and doesn't support defining default values (or any constructor work) in the mixin (only on the composite object).
I wrote an alternative mixin pattern for a charting library I'm working on. My library expects a lot of input and has a lot of output depending on what pieces end up in the final chart. I have a "chart context" and each chart widget can contribute to the chart context with a mixin.
I can currently see a few cons to my approach.
- It doesn't support mixins which need multiple inheritance (I suppose I could create such a mixin using
applyMixins
but that seems a little complex). - It doesn't support adding methods and functions to the final composite object (this is really just another case of the above point)
- If multiple classes extend the same base class then that base class' constructor would be called multiple times.
These issues are not a concern for my application but I am not a Javascript expert and this code, though it works, did some pretty deep prototype manipulation. So my main concerns are:
- Is this prototype manipulation invalid and thus will cause issues for me in the future?
- In the TypeScript example they state:
derivedCtor.prototype[name] = baseCtor.prototype[name];
but it seems like it would be better to say:
Object.defineProperty(derivedCtor.prototype, name, Object.getOwnPropertyDescriptor(baseCtor, name));
why didn't they use this approach? Is there some issue with it?
The Code:
import { Subject } from 'rxjs/Subject';
class HasYAxis {
yAxisType: 'linear' | 'log' = 'linear';
}
class BaseChart {
size = { width: 0, height: 0 };
get hasSize() { return !!this.size && this.size.width > 0 && this.size.height > 0; }
}
class BarChart extends BaseChart {
bins: number[];
yAxisType: 'linear' | 'log' = 'linear';
binSelected = new Subject<number>();
binHidden = new Subject<number>();
get binWidth() { return this.size.width / this.bins.length; }
getTotalBinCount() { return this.bins.reduce((a, b) => a + b, 0); }
}
interface Constructor<T> { new(): T; }
function getParents(ctor: Constructor<any>) {
const parents = [];
let parent = Object.getPrototypeOf(ctor.prototype);
while (parent !== Object.prototype) {
parents.push(parent);
parent = Object.getPrototypeOf(parent);
}
return parents;
}
function getAllPrototypes(ctors: Constructor<any>[]) {
const allCtors = new Set<Constructor<any>>();
for (let ctor of ctors) {
allCtors.add(ctor.prototype);
for (let parent of getParents(ctor)) {
allCtors.add(parent);
}
}
return allCtors;
}
// I'm not sure the name for this. I call it Christmas tree overloading.
// I've seen it used in a few places where a method could take in a
// hetergeneous array and the result type depends on the type of the
// inputs. I'm aware of the obvious cons (lots of typing and gives back
// any if passed in more than 5 arguments) and not too concerned about it.
function applyMixins<T1, T2>(t1: Constructor<T1>, t2: Constructor<T2>): Constructor<T1 & T2>;
function applyMixins<T1, T2, T3>(t1: Constructor<T1>, t2: Constructor<T2>, t3: Constructor<T3>): Constructor<T1 & T2 & T3>;
function applyMixins<T1, T2, T3, T4>(t1: Constructor<T1>, t2: Constructor<T2>, t3: Constructor<T3>, t4: Constructor<T4>): Constructor<T1 & T2 & T3 & T4>;
function applyMixins<T1, T2, T3, T4, T5>(t1: Constructor<T1>, t2: Constructor<T2>, t3: Constructor<T3>, t4: Constructor<T4>, t5: Constructor<T5>): Constructor<T1 & T2 & T3 & T4 & T5>;
function applyMixins(...ctors: Constructor<any>[]) {
// The constructor for the composite object will simply call the constructors for each of the mixins. This adds
// support for defining default values as well as construction initialization in the mixins.
const result = class {
constructor() {
// Don't include parents here, assume base ctors will call super()
for (let ctor of ctors) {
ctor.prototype.constructor.apply(this);
}
}
} as any;
// allProtos contains the prototypes of each of the ctors as well as prototypes for any base types they may have
const allProtos = getAllPrototypes(ctors);
allProtos.forEach(proto => {
Object.getOwnPropertyNames(proto).filter(name => name !== 'constructor').forEach(name => {
// Here I am using the property descriptor instead of the value. If I don't (e.g. if I use
// proto[name] then getter/setter based properties will not work correctly).
let propertyDescriptor = Object.getOwnPropertyDescriptor(proto, name);
Object.defineProperty(result.prototype, name, propertyDescriptor);
});
});
return result;
}
let chartContextClass = applyMixins(HasYAxis, BarChart);
let chartContext = new chartContextClass();
chartContext.bins = [1, 2];
chartContext.size = { width: 10, height: 10 };
console.log(chartContext.hasSize); // true
console.log(chartContext.binWidth); // 5
console.log(chartContext.getTotalBinCount()); // 3
console.log(chartContext.yAxisType); // linear