For the purposes of this question, consider a "mixin" to be a function as described at https://www.typescriptlang.org/docs/handbook/mixins.html. In this case, the mixin extends the class receiving the mixin. I'm attempting to do something different: enable "traits", which I'm defining here to be reusable classes that provide public & non-public instance members that can be inherited and overridden by a class that extends the trait, unlike a mixin.
Attempts at a solution follow, but the typings aren't quite right, and that's the part I'm stuck on. Note that this works perfectly in JavaScript, as evidenced by the npm package I authored, @northscaler/mutrait.
My question is how do I change the type definitions below in order for the code to compile and for the tests to pass?
First, here's the module, traitify.ts, that tries to be the "library" for this (and whose type definitions I know aren't correct):
// in file traitify.ts
/**
* Type definition of a constructor.
*/
export type Constructor<T> = new(...args: any[]) => T;
/**
* A "trait" is a function that takes a superclass `S` and returns a new class `T extends S`.
*/
export type Trait<S extends Constructor<object>, T extends S> = (superclass: S) => T
/**
* Convenient function when defining a class that
* * extends a superclass, and
* * expresses one or more traits.
*/
export const superclass = <S extends Constructor<object>>(s?: S) => new TraitBuilder(s)
/**
* Convenient function to be used when a class
* * does not extend a superclass, and
* * expresses multiple traits.
*/
export const traits = <S extends Constructor<object>, T extends S>(t: Trait<S, T>) => superclass().with(t)
/**
* Convenient function to be used when defining a class that
* * does not extend a superclass, and
* * expresses exactly one trait.
*/
export const trait = <S extends Constructor<object>, T extends S>(t: Trait<S, T>) => traits(t).apply()
/**
* A convenient trait applier class that uses a builder pattern to apply traits.
*/
class TraitBuilder<S extends Constructor<object>> {
superclass: S;
constructor (superclass?: S) {
this.superclass = superclass || class {} as S // TODO: remove "as S" when figured out
}
/**
* Applies the trait to the current superclass then returns a new `TraitBuilder`.
* @param trait The trait that the current superclass should express.
*/
with <S extends Constructor<object>, T extends S>(trait: Trait<S, T>) {
// we have to return a new builder here because there's no way to take a collection of traits of differing types.
return new TraitBuilder(trait(this.superclass))
}
/**
* Return the class with all traits expressed.
*/
apply() {
return this.superclass || class {}
}
}
I'd like to be able to define a Taggable trait, in Taggable.ts, like the following, where the trait defines a protected _tag field, and provides a default implementation of a tag property:
// in file Taggable.ts
import { Constructor } from './traitify';
export interface ITaggable {
tag?: string;
}
export const Taggable = <S extends Constructor<object>>(superclass: S) =>
class extends superclass implements ITaggable {
_tag?: string; // TODO: make protected when https://github.com/microsoft/TypeScript/issues/36060 is fixed
get tag() {
return this._tag;
}
set tag(tag) {
this._doSetTag(this._testSetTag(tag));
}
constructor(...args: any[]) {
super(...args);
}
_testSetTag(tag?: string) { // TODO: make protected
return tag;
}
_doSetTag(tag?: string) { // TODO: make protected
this._tag = tag;
}
};
The default implementation of the tag property is intentional, because in this pattern, I want to allow classes that extend the trait to override only those members of the trait that it wishes to.
While keeping the example minimal but thorough, I have to include one more sample trait to illustrate the pattern when a class is extending multiple traits, so here is a Nameable trait, very similar to Taggable above.
// in file Nameable.ts
import { Constructor } from './traitify';
export interface INameable {
name?: string;
}
export const Nameable = <S extends Constructor<object>>(superclass: S) =>
class extends superclass implements INameable {
_name?: string; // TODO: make protected when https://github.com/microsoft/TypeScript/issues/36060 is fixed
get name() {
return this._name;
}
set name(name) {
this._doSetName(this._testSetName(name));
}
constructor(...args: any[]) {
super(...args);
}
_testSetName(name?: string) { // TODO: make protected
return name;
}
_doSetName(name?: string) { // TODO: make protected
this._name = name;
}
};
Now, with our traitify library & two traits, here are the tests that I'm trying to get to pass, which illustrate how a consumer of the trait would use it:
import { trait, superclass } from './traitify';
import test from 'ava';
import { Taggable } from './Taggable';
import { Nameable } from './Nameable';
test('express a single trait with no superclass', (t) => {
class Point extends trait(Taggable) {
constructor(public x: number, public y: number) {
super(...arguments);
this.x = x;
this.y = y;
}
_testSetTag(tag?: string) {
tag = super._testSetTag(tag);
if (!tag) throw new Error('no tag given');
else return tag.toLowerCase();
}
}
const point = new Point(10, 20);
point.tag = 'hello';
t.is(point.tag, 'hello');
t.throws(() => point.tag = '');
});
test('express a single trait and extend a superclass', (t) => {
class Base {
something: string = 'I am a base';
}
class Sub extends superclass(Base)
.with(Taggable).apply() {
constructor() {
super(...arguments);
}
_testSetTag(tag?: string): string | undefined {
tag = super._testSetTag(tag);
if (tag === 'throw') throw new Error('illegal tag value');
return tag;
}
}
const sub = new Sub();
t.assert(sub instanceof Sub);
t.assert(sub instanceof Base);
sub.tag = 'sub';
t.is(sub.tag, 'sub');
t.throws(() => sub.tag = 'throw');
});
test('express multiple traits and extend a superclass', (t) => {
class Animal {
}
class Person extends superclass(Animal)
.with(Nameable)
.with(Taggable).apply() {
constructor(...args: any[]) {
super(args);
}
_testSetName(name?: string) {
if (!name) throw new Error('no name given');
return name.trim();
}
}
const person = new Person();
t.assert(person instanceof Person);
t.assert(person instanceof Animal);
person.name = 'Felix';
t.is(person.name, 'Felix');
t.throws(() => person.name = null);
});
test('superclass expresses a trait, subclass expresses another trait but overrides method in superclass\'s trait', (t) => {
class Animal extends trait(Nameable) {
constructor(...args: any[]) {
super(args);
}
_testSetName(name?: string) {
if (!name) throw new Error('no name given');
if (name.toLowerCase().includes('animal')) throw new Error('name must include "animal"');
return name;
}
}
const animal = new Animal();
animal.name = 'an animal';
t.is(animal.name, 'an animal');
t.throws(() => animal.name = 'nothing');
class Person extends superclass(Animal)
.with(Taggable).apply() {
constructor(...args: any[]) {
super(args);
}
_testSetName(name?: string) {
if (!name) throw new Error('no name given');
if (name.toLowerCase().includes('person')) throw new Error('name must include "person"');
return name;
}
}
const person = new Person();
t.assert(person instanceof Person);
t.assert(person instanceof Animal);
person.name = 'a person';
t.is(person.name, 'a person');
t.throws(() => person.name = 'an animal');
t.throws(() => person.name = 'nothing');
});
The compiler error that I'm getting is the following:
src/lib/traitify.spec.ts:84:10 - error TS2339: Property 'name' does not exist on type 'Person'.
84 person.name = 'Felix';
~~~~
src/lib/traitify.spec.ts:86:15 - error TS2339: Property 'name' does not exist on type 'Person'.
86 t.is(person.name, 'Felix');
~~~~
src/lib/traitify.spec.ts:87:25 - error TS2339: Property 'name' does not exist on type 'Person'.
87 t.throws(() => person.name = null);
~~~~
src/lib/traitify.spec.ts:127:10 - error TS2339: Property 'name' does not exist on type 'Person'.
127 person.name = 'a person';
~~~~
src/lib/traitify.spec.ts:129:15 - error TS2339: Property 'name' does not exist on type 'Person'.
129 t.is(person.name, 'a person');
~~~~
src/lib/traitify.spec.ts:130:25 - error TS2339: Property 'name' does not exist on type 'Person'.
130 t.throws(() => person.name = 'an animal');
~~~~
src/lib/traitify.spec.ts:131:25 - error TS2339: Property 'name' does not exist on type 'Person'.
131 t.throws(() => person.name = 'nothing');
~~~~
src/lib/traitify.ts:48:35 - error TS2345: Argument of type 'S' is not assignable to parameter of type 'S'.
'S' is assignable to the constraint of type 'S', but 'S' could be instantiated with a different subtype of constraint 'Constructor<object>'.
Type 'Constructor<object>' is not assignable to type 'S'.
'Constructor<object>' is assignable to the constraint of type 'S', but 'S' could be instantiated with a different subtype of constraint 'Constructor<object>'.
48 return new TraitBuilder(trait(this.superclass))
~~~~~~~~~~~~~~~
Found 8 errors.
NB: there is a Git repo available for this if you want to play with it at https://github.com/matthewadams/typescript-trait-test. To play, execute git clone https://github.com/matthewadams/typescript-trait-test && cd typescript-trait-test && npm install && npm test.
NB: I feel that this really is as minimal as I can provide that demonstrates the pattern that I'm trying to enable.