Как мне нужно изменить эти определения типов mixin в TypeScript, чтобы разрешить определение mixins, которые позволяют классу расширять признак?

#javascript #typescript #traits #multiple-inheritance #mixins

#javascript #typescript #Трейты #множественное наследование #mixins

Вопрос:

Для целей этого вопроса рассмотрим «mixin» как функцию, как описано в https://www.typescriptlang.org/docs/handbook/mixins.html . В этом случае mixin расширяет класс, получающий mixin. Я пытаюсь сделать что-то другое: включить «черты», которые я определяю здесь как повторно используемые классы, которые предоставляют общедоступные и непубличные члены экземпляра, которые могут быть унаследованы и переопределены классом, который расширяет признак, в отличие от mixin.

Далее следуют попытки найти решение, но типизация не совсем правильная, и это та часть, на которой я застрял. Обратите внимание, что это отлично работает в JavaScript, о чем свидетельствует пакет npm, который я создал, @northscaler/mutrait.

Мой вопрос заключается в том, как мне изменить приведенные ниже определения типов, чтобы код был скомпилирован и тесты прошли?

Во-первых, вот модуль, traitify.ts , который пытается быть «библиотекой» для этого (и определения типов которого, как я знаю, неверны):

 // 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 {}
  }
}

 

Я хотел бы иметь возможность определять Taggable признак Taggable.ts , например, следующим образом, где признак определяет защищенное _tag поле и предоставляет реализацию tag свойства по умолчанию:

 // 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 extend ing 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 amp; 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');
});
 

Ошибка компилятора, которую я получаю, заключается в следующем:

 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.
 

ПРИМЕЧАНИЕ: для этого доступно репозиторий Git, если вы хотите поиграть с ним в https://github.com/matthewadams/typescript-trait-test . Чтобы играть, выполняйте git clone https://github.com/matthewadams/typescript-trait-test amp;amp; cd typescript-trait-test amp;amp; npm install amp;amp; npm test .

ПРИМЕЧАНИЕ: Я чувствую, что это действительно минимально, насколько я могу предоставить, что демонстрирует шаблон, который я пытаюсь включить.

Комментарии:

1. Вы проделали здесь действительно хорошую работу, и я не уверен, насколько лучше вы можете добиться, учитывая ограничения typescript в отношении частных / защищенных свойств в mixins. .with() Системе потенциально нужен лучший возвращаемый тип, но я должен обдумать это.

2. Моя первая догадка заключается в том, что вместо использования универсального для конструктора класса, S extends Constructor<object> возможно, было бы лучше использовать универсальный для экземпляра класса <S extends Something>(superclass: Constructor<S>) и явно объявлять возвращаемый тип Constructor<S amp; AddedStuff> .

3. @LindaPaiste хорошо, я подумаю об этом. Я просто чувствую, что это так близко к тому, что я хочу, чтобы это было! 🤓

4. @LindaPaiste Смотрите мой ответ ниже. Я думаю, что это самое простое и лучшее, что я могу сделать, и я собираюсь работать с ним. 🙂

Ответ №1:

Итак, я наконец-то разработал несовершенный, но полезный шаблон признаков в TypeScript.

TL; DR: Если вы просто хотите увидеть код, демонстрирующий шаблон, он находится здесь. Посмотрите в папках main amp; test .

A trait , для целей этого обсуждения, — это просто функция, которая принимает необязательный суперкласс и возвращает новый класс, который расширяет данный суперкласс и реализует один или несколько специфичных для характеристики интерфейсов. Это комбинация вариации хорошо известного шаблона фабрики подклассов, рассмотренного здесь, и типов пересечений.

Вот обязательное, хотя и слишком минимальное: «Привет, мир!».

Это библиотека, если вы хотите ее так назвать:

 /**
 * 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 amp; TraitInterface`.
 */
// eslint-disable-next-line @typescript-eslint/ban-types
export type Trait<Superclass extends Constructor<object>, TraitInterface> = (
  superclass: Superclass
) => Constructor<Superclass amp; TraitInterface>
 

Вот Greetable черта, которая придает greeting свойство и способ приветствовать кого-то. Обратите внимание , что он включает в себя интерфейс для публичного поведения , которое возвращает признак 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}!`
    }
  }
 

Вот как вы пишете класс для выражения признака. Это из моих модульных тестов mocha.

 // 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!')
  })
 

Теперь, когда вы увидели самый простой из возможных примеров, позвольте мне поделиться более реалистичным «Привет, мир!», Который демонстрирует кое-что более полезное. Это еще одна приветствуемая черта, но ее поведение настраивается выражающим классом.

 // 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 amp; 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
    }
  }
 

Ключевой особенностью этого является включение двух интерфейсов, одного для общедоступного поведения и одного для непубличного поведения. Public Интерфейс представляет поведение, видимое клиентам классов, которые выражают признак. Implementation Интерфейс представляет детали реализации как интерфейс, и Implementation extends Public . Затем функция trait возвращает класс, который implements Implementation .

В этой реализации _testSetGreeting метод проверяет, очищает и возвращает заданное значение, и _doSetGreeting фактически устанавливает свойство backing _greeting .

Теперь класс, который выражает признак, может переопределять все, что ему нужно, чтобы настроить поведение. Этот пример переопределяет _testSetGreeting , чтобы гарантировать, что приветствие выдается и что приветствие обрезается.

 // 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()
  })
 

В репозитории есть более подробные примеры.

Иногда TypeScript все равно ошибается, обычно, когда вы выражаете более одной черты, что очень часто. Есть удобный способ, э-э, помочь TypeScript получить правильный тип классов, выражающих признаки: уменьшить область действия конструктора класса expressing protected и создать статический фабричный метод, new который возвращает экземпляры класса expressing, используя as для указания TypeScript правильного типа. Вот пример.

   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 amp; Taggable.Public amp; 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()
  })
 

Цена, которую нужно заплатить, довольно мала: вы используете что-то вроде const it = It.new() вместо const it = new It() . Это продвинет вас немного дальше по пути, но вам все равно придется добавить // @ts-ignore здесь и там, чтобы TypeScript знал, что вы знаете, что делаете.

Наконец, есть одно ограничение. Если вы хотите, чтобы класс выражал черты и расширял базовый класс, черты будут переопределять методы базового класса, если они имеют одинаковые имена. На практике, однако, это не является серьезным ограничением, потому что, как только вы принимаете шаблон признаков, он эффективно заменяет традиционное использование extends .

Вы всегда должны создавать любой повторно используемый код на основе классов в качестве признака, затем ваши классы выражают признаки, а не расширяют базовые классы.

Краткие сведения

Я знаю, что это не идеально, но работает достаточно хорошо. Я бы предпочел, чтобы TypeScript предоставлял trait / mixin amp; with ключевые слова, которые являются синтаксическим сахаром для этого шаблона, очень похожие на черты mixin Dart with amp; или Scala, которые заменяют что-то похожее на этот шаблон, за исключением того, что будут решены все проблемы с областью видимости и безопасностью типов (а также решение проблемы с бриллиантами).

Примечание: Уже есть предложение JavaScript для миксинов.

Мне потребовалось много времени, чтобы определить этот паттерн, и он все еще неправильный, но на данный момент он достаточно хорош. Этот шаблон признаков отлично работал для меня в JavaScript, но у меня всегда были проблемы с попытками реализовать тот же шаблон в TypeScript (и я не единственный).

Существуют и другие решения, которые пытаются решить эту проблему, но то, что я предлагаю здесь, имеет хороший баланс между простотой, удобочитаемостью, понятностью и мощью. Дай мне знать, что ты думаешь.