1.0.2 • Published 3 years ago

ts-yamp v1.0.2

Weekly downloads
3
License
MIT
Repository
github
Last release
3 years ago

ts-yamp

Yet another mixin pattern (in TypeScript).

TypeScript Handbook recommends two mixin patterns. In my humble opinion the alternative pattern is closer to what a mixin should be : prototype of mixins are included in the target class prototype. The fact is the proposed implementation is not type safe ; this project aims to build mixins this way but with type safety.

Install

npm i -D ts-yamp

Usage

In the following example the Bird class includes the Singer mixin :

import { mixin } from "ts-yamp";

class Singing {
  constructor(public when?: string) {}

  sing(): string {
    const when = this.when ?? "in the morning.";
    return `I sing like a bird ${when}`;
  }
}

class Bird {
  constructor(public name: string) {}
}

const SingingBird = mixin(Bird).with(Singing).get();
const bird = new SingingBird("Tweety");
console.log(bird.sing());
// I sing like a bird in the morning.

The sing method is added to the Bird class prototype. Actually Bird and SingingBrid references the same class but the second has type Bird & Singing type.

console.log(SingingBird == Bird);
// true

In order to prevent Bird class to be modified, another receiver class should be provided :

const SingingBird = mixin(class box {})
  .with(Bird)
  .with(Singing)
  .get();

const bird = new SingingBird();

Now, SingingBird has type box & Bird & Singing and references the same class as box declared inline in the mixin function call. An empty constructor function can also be passed to the mixin function :

const t = mixin(function () {})
  .with(Bird)
  .with(Singing)
  .get();

The constructor function may be used to initialize the class instances :

function birdCtor(this: Bird & Singing, name: string, when?: string) {
  this.name = name;
  this.when = when ?? "when I want.";
}

const SingingBird = mixin(birdCtor).with(Bird).with(Singing).get();

const bird = new SingingBird("Tweety", "every day.");
console.log(bird.sing());
// I sing like a bird every day.

The target instance can be properly initialized by calling mixin constructors :

function birdCtor(this: Bird & Singing, name: string, when?: string) {
  Object.assign(this, new Singing(when), new Bird(name));
}
const SingingBird = mixin(birdCtor).with(Bird).with(Singing).get();

const bird1 = new SingingBird("Tweety");
const bird2 = new SingingBird("Tweety", "every day.");
console.log(bird1.sing());
console.log(bird2.sing());
// I sing like a bird in the morning.
// I sing like a bird every day.

The same can be achieved using a class :

class ABird {
  constructor(name: string, when?: string) {
    Object.assign(this, new Singing(when), new Bird(name));
  }
}
const SingingBird = mixin(ABird).with(Bird).with(Singing).get();

Methods can be overriden ; overriden methods won't be replaced by mixin's same name methods. The type of the mixins has to be annotated in the overriden methods signature :

class Person {
  constructor(public name: string) {}
}

class Bob {
  constructor(when?: string) {
    Object.assign(this, new Singing(when), new Person("Bob"));
  }

  sing(this: Person & Singing) {
    return `I sing in the shower ${this.when}`;
  }
}
const BobSings = mixin(Bob).with(Person).with(Singing).get();

const bob = new BobSings("when the sun is shining.");
console.log(bob.sing());
// I sing in the shower when the sun is shining.

If needed the minxin orignal methods may be called from the target class. The construction of the class is simply broken down into two steps :

const traits = mixin.with(Person).with(Singing);
class Alice {
  constructor(when?: string) {
    Object.assign(this, new Singing(when), new Person("Alice"));
  }

  sing(this: Person & Singing) {
    const wrong = traits.prototype.sing.call(this);
    return `I would like to say that ${wrong} But I don't.`;
  }
}
const AliceSings = mixin(Alice).with(traits).get();

const alice = new AliceSings();
console.log(alice.sing());
// I would like to say that I sing like a bird in the morning. But I don't.

Another (maybe better) way to do the same, using an intermediate mixin :

function baseCtor(this: Person & Singing, name: string, when?: string) {
  Object.assign(this, new Singing(when), new Bird(name));
}
const Base = mixin(baseCtor).with(Person).with(Singing).get();

class Alice {
  private readonly base: Person & Singing;

  constructor(when?: string) {
    this.base = new Base("Alice", when);
    Object.assign(this, this.base);
  }

  sing() {
    const wrong = this.base.sing();
    return `I would like to say that ${wrong} But I don't.`;
  }
}
const AliceSings = mixin(Alice).with(Base).get();

In this example, AliceSings is a value, not a type. Instances will have type Alice & Person & Singing. This may be confusing when using it out of the module it is built. A type with the same name can be declared :

const traits = mixin.with(Person).with(Singing);
class Alice {
  constructor(when?: string) {
    Object.assign(this, new Singing(when), new Person("Alice"));
  }

  sing(this: AliceSings) {
    const wrong = traits.prototype.sing.call(this);
    return `I would like to say that ${wrong} But I don't.`;
  }
}

interface AliceSings extends Alice, Person, Singing {}
const AliceSings = mixin(Alice).with(traits).get();

export default AliceSings

About the implementation

The implementation is largely based on the alternative pattern presented in TypeScript Handbook. Type are enforced by generics in the mixWith function that mixes two classes :

type Ctor<T, S extends unknown[]> = new (...args: S) => T;

function mixWith<T, S extends unknown[], U>(
  ctor: Ctor<T, S>,
  trait: Ctor<U, unknown[]>
): Ctor<T & U, S> {
  Object.getOwnPropertyNames(trait.prototype)
    .filter((m) => !(m in ctor.prototype))
    .forEach(copy);
  return ctor as Ctor<T & U, S>;

  function copy(prop: string) {
    Object.defineProperty(
      ctor.prototype,
      prop,
      Object.getOwnPropertyDescriptor(
        trait.prototype,
        prop
      ) as PropertyDescriptor
    );
  }
}

The mixin method just return an object that leverage the mixWith method and implements the fluent interface :

interface MixinBuilder<T, S extends unknown[]> {
  get(): Ctor<T, S>;

  readonly prototype: ProtoOf<T>;

  with<U, R extends unknown[]>(
    builder: MixinBuilder<U, R>
  ): MixinBuilder<T & U, S>;

  with<U, R extends unknown[]>(trait: Ctor<U, R>): MixinBuilder<T & U, S>;
}

This makes it possible to compose several types while keeping type safety.

1.0.2

3 years ago

1.0.1

3 years ago

1.0.0

3 years ago

0.10.0

3 years ago

0.9.0

3 years ago