How do I need to change these TypeScript mixin type definitions in order to allow the definition of mixins that allow a class to extend the trait?

Viewed 1053

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.

1 Answers

Ok, I finally worked out an imperfect but usable trait pattern in TypeScript.

TL;DR: If you just want to see the code that demonstrates the pattern, it's here. Look in the main & test folders.

A trait, for the purposes of this discussion, is simply a function that accepts an optional superclass and returns a new class that extends the given superclass and implements one or more trait-specific interfaces. This is is a combination of a variation of the well-known subclass factory pattern seen here and intersection types.

Here's the obligatory, albeit too minimal, "Hello, world!".

This is the library, if you want to call it that:

/**
 * Type definition of a constructor.
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type Constructor<T> = new (...args: any[]) => T

/**
 * The empty class.
 */
export class Empty {}

/**
 * A "trait" is a function that takes a superclass of type `Superclass` and returns a new class that is of type `Superclass & TraitInterface`.
 */
// eslint-disable-next-line @typescript-eslint/ban-types
export type Trait<Superclass extends Constructor<object>, TraitInterface> = (
  superclass: Superclass
) => Constructor<Superclass & TraitInterface>

Here's a Greetable trait, that imparts a greeting property and a method to greet someone. Note that it includes an interface for the public behavior that the returned trait implements.

// in file Greetable.ts

import { Constructor, Empty } from '../main/traitify'

/*
 * Absolutely minimal demonstration of the trait pattern, in the spirit of "Hello, world!" demos.
 * This is missing some common stuff because it's so minimal.
 * See Greetable2 for a more realistic example.
 */

/**
 * Public trait interface
 */
export interface Public {
  greeting?: string

  greet(greetee: string): string
}

/**
 * The trait function.
 */
// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/explicit-module-boundary-types
export const trait = <S extends Constructor<object>>(superclass?: S) =>
  /**
   * Class that implements the trait
   */
  class Greetable extends (superclass || Empty) implements Public {
    greeting?: string

    /**
     * Constructor that simply delegates to the super's constructor
     */
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    constructor(...args: any[]) {
      super(...args)
    }

    greet(greetee: string): string {
      return `${this.greeting}, ${greetee}!`
    }
  }

Here's how you write a class to express the trait. This is from my mocha unit tests.

// in file traitify.spec.ts

  it('expresses the simplest possible "Hello, world!" trait', function () {
    class HelloWorld extends Greetable.trait() {
      constructor(greeting = 'Hello') {
        super()
        this.greeting = greeting
      }
    }

    const greeter = new HelloWorld()

    expect(greeter.greet('world')).to.equal('Hello, world!')
  })

Now that you've seen the simplest possible example, let me share a more realistic "Hello, world!" that demonstrates something a little bit more useful. This is another Greetable trait, but its behavior is customizable by the expressing class.

// in file Greetable2.ts

import { Constructor, Empty } from '../main/traitify'

/**
 * Public trait interface
 */
export interface Public {
  greeting?: string

  greet(greetee: string): string
}

/**
 * Nonpublic trait interface
 */
export interface Implementation {
  _greeting?: string

  /**
   * Validates, scrubs & returns given value
   */
  _testSetGreeting(value?: string): string | undefined

  /**
   * Actually sets given value
   */
  _doSetGreeting(value?: string): void
}

/**
 * The trait function.
 */
// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/explicit-module-boundary-types
export const trait = <S extends Constructor<object>>(superclass?: S) =>
  /**
   * Class that implements the trait
   */
  class Greetable2 extends (superclass || Empty) implements Implementation {
    _greeting?: string

    /**
     * Constructor that simply delegates to the super's constructor
     */
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    constructor(...args: any[]) {
      super(...args)
    }

    get greeting() {
      return this._greeting
    }

    set greeting(value: string | undefined) {
      this._doSetGreeting(this._testSetGreeting(value))
    }

    greet(greetee: string): string {
      return `${this.greeting}, ${greetee}!`
    }

    _testSetGreeting(value?: string) {
      return value
    }

    _doSetGreeting(value?: string) {
      this._greeting = value
    }
  }

This has the key feature of including two interfaces, one for the public behavior and one for the non-public behavior. The Public interface represents the behavior visible to clients of the classes that express the trait. The Implementation interface represents the details of the implementation as an interface, and Implementation extends Public. The trait function then returns a class that implements Implementation.

In this implementation, the _testSetGreeting method validates, scrubs & returns the value being set, and the _doSetGreeting actually sets the backing property _greeting.

Now, the class that expresses the trait can override whatever it needs to in order to customize the behavior. This example overrides _testSetGreeting to ensure that a greeting is given and that the greeting is trimmed.

// in file traitify.spec.ts

  it('expresses a more realistic "Hello, world!" trait', function () {
    class HelloWorld2 extends Greetable2.trait() {
      constructor(greeting = 'Hello') {
        super()
        this.greeting = greeting
      }

      /**
       * Overrides default behavior
       */
      _testSetGreeting(value?: string): string | undefined {
        value = super._testSetGreeting(value)

        if (!value) {
          throw new Error('no greeting given')
        }

        return value.trim()
      }
    }

    const greeter = new HelloWorld2()

    expect(greeter.greet('world')).to.equal('Hello, world!')
    expect(() => {
      greeter.greeting = ''
    }).to.throw()
  })

There are more thorough examples in the repo.

Sometimes, TypeScript still gets it wrong, usually when you express more than one trait, which is very common. There's a convenient way to, er, help, TypeScript get the correct type of classes expressing traits: reduce the scope of the expressing class's constructor to protected and create a static factory method called new that returns instances of the expressing class using as to tell TypeScript what the correct type is. Here's an example.

  it('express multiple traits with no superclass', function () {
    class Point2 extends Nameable.trait(Taggable.trait()) {
      // required to overcome TypeScript compiler bug?
      static new(x: number, y: number) {
        return new this(x, y) as Point2 & Taggable.Public & Nameable.Public
      }

      protected constructor(public x: number, public y: number) {
        super(x, y)
      }

      _testSetTag(tag?: string) {
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
        tag = super._testSetTag(tag)

        if (!tag) throw new Error('no tag given')
        else return tag.toLowerCase()
      }

      _testSetName(name?: string) {
        name = super._testSetName(name)

        if (!name) throw new Error('no name given')
        else return name.toLowerCase()
      }
    }

    const point2 = Point2.new(10, 20)
    point2.tag = 'hello'

    expect(point2.tag).to.equal('hello')
    expect(() => (point2.tag = '')).to.throw()
  })

The price to pay is fairly small: you use something like const it = It.new() instead of const it = new It(). This gets you a little farther down the path, but you'll still have to sprinkle a // @ts-ignore here & there to let TypeScript know that you know what you're doing.

Lastly, there is one limitation. If you want a class to express traits and extend a base class, the traits would override the base class's methods if they have the same names. In practice, however, this is not a serious limitation, because once you adopt the trait pattern, it effectively replaces traditional use of extends.

You should always author any reusable class-based code as a trait, then have your classes express the traits, not extend base classes.

Summary

I know this is not perfect, but it works well enough. I'd much rather see TypeScript provide trait/mixin & with keywords that are syntax sugar for this pattern, much like Dart's mixin & with or Scala's traits, that desugars to something similar to this pattern, except that all scope & type safety issues would be addressed (as well as a solution to the diamond problem).

NB: There's already a JavaScript proposal for mixins.

This pattern has taken me a long time to identify, and it's still not right, but it's good enough for now. This trait pattern has worked great for me in JavaScript, but I've always had issues trying to manifest the same pattern in TypeScript (and I'm not the only one).

Now, there are other solutions out there that try to tackle this problem, but what I propose here has a nice balance between simplicity, readability, comprehensibility & power. Let me know what you think.

Related