问题描述
就本问题而言,将“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>) => 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
trait,如下所示,其中 trait 定义了一个受保护的 _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;
}
};
tag
属性的默认实现是有意的,因为在此模式中,我希望允许 extend
trait 的类仅覆盖它希望覆盖的 trait 成员。>
在保持示例最少但全面的同时,我必须再包含一个示例特征来说明当一个类extend
使用多个特征时的模式,所以这里有一个 Nameable
特性,与上面的 Taggable
非常相似。
// 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;
}
};
现在,有了我们的 traitify
库和两个 trait,下面是我试图通过的测试,这些测试说明了 trait 的使用者将如何使用它:
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.
注意:如果您想在 https://github.com/matthewadams/typescript-trait-test. 使用它,有一个可用的 Git 存储库要使用它,请执行 git clone https://github.com/matthewadams/typescript-trait-test && cd typescript-trait-test && npm install && npm test
。
注意:我觉得这真的是我所能提供的最简单的展示我正在尝试启用的模式。
解决方法
好的,我终于在 TypeScript 中找到了一个不完美但可用的特征模式。
TL;DR:如果您只想查看演示模式的代码,它是 here。查看 main
和 test
文件夹。
就本讨论而言,trait
只是一个函数,它接受一个可选的超类并返回一个扩展给定超类的新类并实现一个或多个特征-具体接口。这是众所周知的子类工厂模式here 和intersection types 的变体的组合。
这是强制性的,尽管太,“你好,世界!”。
这是图书馆,如果你想这样称呼它:
/**
* 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>
这是一个 Greetable
trait,它赋予一个 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!')
})
既然您已经看到了最简单的示例,让我分享一个更现实的“Hello,world!”这证明了一些更有用的东西。这是另一个 Greetable trait,但它的行为可以由表达类自定义。
// 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
}
}
这具有包括两个接口的关键特性,一个用于公共行为,一个用于非公共行为。 Public
接口表示对表达特征的类的客户端可见的行为。 Implementation
接口表示作为接口的实现细节,而 Implementation extends Public
。然后特征函数返回一个 implements Implementation
类。
在这个实现中,_testSetGreeting
方法验证、清理并返回设置的值,而 _doSetGreeting
实际上设置了后备属性 _greeting
。
现在,表达 trait 的类可以覆盖它需要的任何东西以自定义行为。此示例覆盖 _testSetGreeting
以确保提供问候语并修剪问候语。
// in file traitify.spec.ts
it('expresses a more realistic "Hello,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()
})
repo 中有更详尽的示例。
有时,TypeScript 仍然会出错,通常是当您表达多个特征时,这种情况非常。有一个方便的方法,呃,帮助,TypeScript 获得正确类型的表达 trait 的类:将表达类的构造函数的范围缩小到 protected
并创建一个名为 {{ 1}} 返回表达类的实例,使用 new
告诉 TypeScript 正确的类型是什么。这是一个例子。
as
要付出的代价相当小:您使用 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()
})
之类的东西而不是 const it = It.new()
。这会让你走得更远,但你仍然需要在这里和那里撒一个 const it = new It()
,让 TypeScript 知道你知道你在做什么。
最后,有一个限制。如果你想要一个类来表达特征并扩展一个基类,如果它们具有相同的名称,这些特征将覆盖基类的方法。然而,在实践中,这并不是一个严重的限制,因为一旦你采用了 trait 模式,它就会有效地取代// @ts-ignore
的传统用法。
你应该总是编写任何可重用的基于类的代码作为特征, 然后让你的类表达特征,而不是扩展基类。
总结
我知道这并不完美,但它运行良好。我更更愿意看到 TypeScript 提供 extends
/trait
和 mixin
关键字作为此模式的语法糖,很像 Dart's mixin
& with
或 { {3}},除此之外,所有范围和类型安全问题都将得到解决(以及 Scala's traits 的解决方案)。
注意:已经有一个 diamond problem。
这个模式花了我很长时间来识别,它仍然不正确,但现在已经足够了。这个 trait 模式有 JavaScript proposal for mixins,但我一直在尝试在 TypeScript 中表现相同的模式时遇到问题(而且我不是唯一一个)。
现在,还有其他解决方案试图解决这个问题,但我在这里提出的方案在简单性、可读性、可理解性和强大功能之间取得了很好的平衡。让我知道你的想法。