Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mixin classes #13743

Merged
merged 4 commits into from
Jan 30, 2017
Merged

Mixin classes #13743

merged 4 commits into from
Jan 30, 2017

Conversation

ahejlsberg
Copy link
Member

@ahejlsberg ahejlsberg commented Jan 30, 2017

This PR expands upon #13604 to add support for mixin classes and constructors. The PR includes type system support for the ECMAScript 2015 mixin class pattern described here and here as well as rules for combining mixin construct signatures with regular construct signatures in intersection types.

In the following, the term mixin constructor type refers to a type that has a single construct signature with a single rest argument of type any[] and an object-like return type. For example, given an object-like type X, new (...args: any[]) => X is a mixin constructor type with an instance type X.

A mixin class is a class declaration or expression that extends an expression of a type parameter type. The following rules apply to mixin class declarations:

  • The type parameter type of the extends expression must be constrained to a mixin constructor type.
  • The constructor of a mixin class (if any) must have a single rest parameter of type any[] and must use the spread operator to pass those parameters as arguments in a super(...args) call.

Given an expression Base of a parametric type T with a constraint X, a mixin class class C extends Base {...} is processed as if Base had type X and the resulting type is the intersection typeof C & T. In other words, a mixin class is represented as an intersection between the mixin class constructor type and the parametric base class constructor type.

When obtaining the construct signatures of an intersection type that contains mixin constructor types, the mixin construct signatures are discarded and their instance types are mixed into the return types of the other construct signatures in the intersection type. For example, the intersection type { new(...args: any[]) => A } & { new(s: string) => B } has a single construct signature new(s: string) => A & B.

Putting all of the above rules together in an example:

class Point {
    constructor(public x: number, public y: number) {}
}

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

type Constructor<T> = new(...args: any[]) => T;

function Tagged<T extends Constructor<{}>>(Base: T) {
    return class extends Base {
        _tag: string;
        constructor(...args: any[]) {
            super(...args);
            this._tag = "";
        }
    }
}

const TaggedPoint = Tagged(Point);

let point = new TaggedPoint(10, 20);
point._tag = "hello";

class Customer extends Tagged(Person) {
    accountBalance: number;
}

let customer = new Customer("Joe");
customer._tag = "test";
customer.accountBalance = 0;

Effectively, a mixin class declaration is required to pass its constructor arguments through to the abstract base class constructor, and the result is an intersection of the declared class constructor and the base class constructor. For example, adding explicit type annotations to the code above:

interface Tagged {
    _tag: string;
}

function Tagged<T extends Constructor<{}>>(Base: T): Constructor<Tagged> & T {
    return class extends Base {
        _tag: string;
        constructor(...args: any[]) {
            super(...args);
            this._tag = "";
        }
    }
}

const TaggedPoint: Constructor<Tagged> & typeof Point = Tagged(Point);

let point: Tagged & Point = new TaggedPoint(10, 20);
point._tag = "hello";

The type of TaggedPoint is an intersection of two constructor types, Constructor<Tagged> and typeof Point. Since Constructor<Tagged> is a mixin constructor type, its construct signature is "mixed into" the constructor for Point. Thus, TaggedPoint has a single construct signature with the same parameter list as Point but with the return type Tagged & Point.

Mixin classes can constrain the types of classes they can mix into by specifying a construct signature return type in the constraint for the type parameter. For example, the following WithLocation function implements a subclass factory that adds a getLocation method to any class that satisfies the Point interface (i.e. that has x and y properties of type number).

interface Point {
    x: number;
    y: number;
}

const WithLocation = <T extends Constructor<Point>>(Base: T) =>
    class extends Base {
        getLocation(): [number, number] {
            return [this.x, this.y];
        }
    }

Fixes #4890.
Fixes #10261.

@pleerock
Copy link

pleerock commented Jan 30, 2017

I guess we can use multiple mixins this way?:

class Customer extends Subscribable(Scorable(Tagged(Person))) { 

Everything fits good into exist language syntax, however it also would be great to have some syntax sugar mixins, maybe even separate keyword, because this does not look good between beautiful domain models and services:

type Constructor<T> = new(...args: any[]) => T;

function Tagged<T extends Constructor<{}>>(Base: T) {
    return class extends Base {

And one another question, is it possible to do:

function Tagged<T extends Constructor<{}>>(Base: T) {
    return class extends Base {
       /// ...
    }
}

class Customer extends Tagged(Person) {
    accountBalance: number;
}

let customer = new Customer("Joe");

and later on to check if customer instanceOf Tagged ? It will be really-really useful to check it in runtime to perform some operations based on it (especially when we can't do same right now with interfaces).

@ahejlsberg
Copy link
Member Author

I guess we can use multiple mixins this way?

Yes.

Everything fits good into exist language syntax, however it also would be great to have some syntax sugar mixins, maybe even separate keyword, because this does not look good between beautiful domain models and services.

We may consider adding the Constructor<T> type to lib.d.ts, but beyond that I wouldn't expect syntactic sugar for the feature.

and later on to check if someCustomer instanceOf Tagged?

You can check if something is an instance of a specific invocation of Tagged like this:

const TaggedPoint = Tagged(Point);

function check(obj: object) {
    if (obj instanceof TaggedPoint) {
        // obj narrowed to mixin type
    }
}

However, you can't use instanceof to determine if an object is some Tagged type, but you could of course write a user defined type predicate to check it.

@justinfagnani
Copy link

@ahejlsberg this is awesome, thanks for making this all work!

Since #4890 was where we were talking about the extends type operator, which would describe the mixin application more accurately than intersection types, should we open a new issue to track that, or it that idea shelved in favor of intersections indefinitely?

@justinfagnani
Copy link

@pleerock it actually is possible to get customer instanceOf Tagged working by implementing the [Symbol.hasInstance] method on Tagged, and having Tagged leave some information on the class it produces.

I talk about this here: http://justinfagnani.com/2016/01/07/enhancing-mixins-with-decorator-functions/ though some members of the V8 team have warned me against using hasInstance at all, since at least at one point it cause a global slowdown of instanceOf.

@ahejlsberg
Copy link
Member Author

ahejlsberg commented Jan 31, 2017

@justinfagnani We considered adding a B extends A type operator (or alternatively an A with B type operator), but we decided against it because we have yet to devise a consistent error reporting scheme for such an operator.

Presumably users would expect B extends A to report errors if A and B have conflicting properties (e.g. when A has a property x: number and B has a property x: string), similarly to how we report conflicts in class and interface declarations. The extends keyword strongly implies such a check. However, when A and/or B are type variables, we don't discover such errors until B extends A is instantiated with actual types. This brings about two issues. (1) Type instantiations are deferred and occur only when they are actually needed. This is pretty much a must in any compiler implementation that supports parametric types. (2) The reasons for type instantiations are not currently tracked (and, indeed, are not easy to track). Because of these issues, errors reported from type instantiations would be inconsistent and unable to exactly pinpoint their cause in the source code. For example, errors resulting from relating types that contain properties using extends types might or might not be reported depending on whether we ever need to check the particular properties (because only then would instantiation happen). So, you might see "phantom" errors that appear and disappear in inexplicable ways. That's not a good experience.

We could of course consider having an extends type operator that doesn't report errors, but that would immediately lead to inconsistencies (because a B extends A would presumably be assignable to A, but might not actually be assignable upon instantiation).

So, the upshot of all of this is that any implementation of extends we could provide would be an unacceptable compromise. For that reason we've chosen to stay with the intersection operator. And, of course, it is always good to not have multiple ways of doing almost the same thing.

@justinfagnani
Copy link

@ahejlsberg Thanks for the detailed explanation.

There were some comments about deferring the extends check until the types are known. Is something like that possible? And excuse my ignorance, I only hang out with compiler people from my days on Dart and from sitting near some V8 folk.

class A {
  x: number;
}

// 1: is it possible to infer here that Base must be extendable by {x: string}?
// extendableBy might be a new internal type operator?
const B = <T extends Constructor<{}>>(Base: T) => class extends Base {
  x: string;
}

// 2: is possible here to check that A is extendable by {x: string}?
// This is where the error would be reported
const C = B(A);

(by the way, this is why I was asking if we should open an issue, for this discussion. I can move it there, and it could be closed so I know it's over :) )

@mjewell
Copy link

mjewell commented Feb 6, 2017

What would the types for a function which applied a mixin look like? I couldn't find a way to get this to work:

type Constructor<T> = new(...args: any[]) => T;

interface IMixin<S extends Constructor<{}>, T> {
  (superclass: S): T; // this return type should be Constructor<T> & S?
}

function applyMixin<S extends Constructor<{}>, T>(Mixin: IMixin<S, T>, SuperClass: S) {
  return Mixin(SuperClass);
}

class SuperClass {
  x() {}
}

function Mixin<T extends Constructor<{}>>(superclass: T) {
  return class extends superclass {
    y() {}
  };
}

const A = applyMixin(Mixin, SuperClass);
const a = new A();
// a.x is not known here
// a.y is known here

const B = Mixin(SuperClass);
const b = new B();
// b.x is known here
// b.y is known here

@ahejlsberg
Copy link
Member Author

@mjewell That's an example of where we need higher-order functions (#9366), i.e. the ability to pass a generic function as an argument to another generic function without erasing the type parameters of the first function.

@mjewell
Copy link

mjewell commented Feb 6, 2017

Thanks for the quick response. Does this also mean there is no way to return a generic class from a mixin? Something like:

class SuperClass<T> {

}

function Mixin<T extends Constructor<SuperClass>>(superclass: T) {
  return class<S> extends superclass<S> {
    y() {}
  };
}

@electricessence
Copy link

Wow. Is this really happening?

@granteagon
Copy link

granteagon commented Apr 25, 2018

@zzmingo I like it but I think mixin would be better than mixins.

class SuperHero mixin Person, CanFly, SuperStrength {}

@granteagon
Copy link

We could also just do something like C#:

class SuperHero extends Person, CanFly, SuperStrength {}

Would work just about the same.

@justinfagnani
Copy link

@granteagon I don't think the TypeScript team wants to add new additional features outside of he type system. There's too much danger of incompatibilities with future JavaScript evolution.

FWIW, I have a proposal to TC39 to add mixins to JavaScript: https://github.com/justinfagnani/proposal-mixins

As that progresses, the TypeScript team could consider adding support. I'm not sure when they start adding proposed features, but probably not earlier than stage 3.

@granteagon
Copy link

@justinfagnani Mixins seem to solve a lot of inflexibility with OOP. They also somewhat bring the functional programming world and OOP world closer together. If there's a way I can show support for you proposal, let me know.

@trusktr
Copy link
Contributor

trusktr commented Jun 17, 2018

Hello everyone, I'm trying to figure out how to type a class-factory. I've an API that takes an object literal, and effectively returns the equivalent of a class.

const Foo = Class(({Private}) => ({
  someMethod() {
    Private(this).somePrivateMethod()
  },
  private: {
    somePrivateMethod() { ... },
  }
}))

const foo = new Foo
foo.someMethod()

where Class is a function that accepts an object literal (or a function that returns an object literal) and returns what is effectively equivalent to a class {}. I am wondering how to begin typing this (if possible).

I made a StackOverflow question about it, and I figured to share it here because the people that know most about class factories in TypeScript are right here. :)

@unional
Copy link
Contributor

unional commented Jun 17, 2018

@trusktr Have not yet think deeply in how to solve your problem, but you might encounter this: #17388 when you create your solution. 🌷

@pleerock
Copy link

In my opinion one of the issues of classes in javascript is absence of mixins. People can do object merging and destructing with plain javascript objects and lot of people simply use factory functions which produce pojos and have all javascript flexibility.

Although it is possible to do mixins right now (this PR) but usage is ugly and probably is used by people on some edge case scenarios.

@trusktr
Copy link
Contributor

trusktr commented Jun 29, 2018

I agree, using TypeScript adds a lot of complexity when trying to do stuff like this that is otherwise simple in plain JavaScript.

@granteagon
Copy link

Seems like we are getting off topic.

@ShanonJackson
Copy link

ShanonJackson commented Aug 21, 2018

@trusktr there is a little known typescript type mostly because its undocumented that will allow you type that called ThisType

And all i solved the Mixin problem today in a correctly typed implementation.

The underlying problem is that the "this" of a class cannot be changed unless via a super-type or itself, this limits and solution because what you want is essentially to manipulate the type of "this" within the class. EDIT: Thought i'd intercept here but i know you can manipulate the "this" within a function but i'm talking about within classes as a whole.

Understanding that i wrote this with some help.

export type Constructor<T = {}> = new (...args: any[]) => T;

/* turns A | B | C into A & B & C */
type UnionToIntersection<U> =
	(U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never

/* merges constructor types - self explanitory */
type MergeConstructorTypes<T extends Array<Constructor<any>>> =
	UnionToIntersection<InstanceType<T[number]>>;

export function Mixin<T extends Array<Constructor<any>>>(constructors: T): Constructor<MergeConstructorTypes<T>> {
	const cls = class {
		state = {
		}
		constructor() {
			constructors.forEach((c: any) => {
				const oldState = this.state;
				c.apply(this);
				this.state = Object.assign({}, this.state, oldState);
			});
		}
	} as any;
	constructors.forEach((c: any) => {
		Object.assign(cls.prototype, c.prototype);
	});
	return cls as any;
}

And the implementation.....

export class FooMixin {
	state = {
		value: ""
	}
	getBlah() {
		return "Blah"
	}
}

export class BarMixin {
	state = {
		othervalue: "merge together"
	}
	getBar() {
		return "Bar"
	}
}

export class FooBar extends Mixin([FooMixin, BarMixin]) {
	constructor() {
		super();
		this.getBlah = this.getBlah.bind(this);
		this.getBar = this.getBar.bind(this);
	}

	test() {
		this.state.value  // here on the typings good :)
		this.state.othervalue // here on the typings good :) this proves the merge strategy for state
		this.getBar(); // here :)
		this.getBlah(); // here :)
	}
}

I feel like this should put this request to rest, it doesn't need special syntax or some hieroglyphics solution that hacks together over complicated typings.
take prototypes -> smash them together -> extend -> correct this.

@zpdDG4gta8XKpMCd
Copy link

zpdDG4gta8XKpMCd commented Aug 21, 2018

i can't believe that this adventure is officially called "mixins":

  • no generics
  • no way for to mix things adhoc, only through laborious class (re)declarations
  • no way to get out of single base class/prototype limitation

and all this is happening in a broad daylight when much better results can be achieved in plain JS by simply calling a bunch of initializers on a bare object

this is a notorious case of where "support idiomatic javascript code" goal is violated

so does typescript have mixins? no it does not

@admosity
Copy link

admosity commented Oct 12, 2018

I did an adaptation of @ShanonJackson's solution and got a pseudo form of generics working. @JustASquid @Aleksey-Bykov I have the same issue as you guys and this seems to be the closest thing I got to generics. This seems to be somewhat "okay" in terms of being able to have stable classes and mixing those in - instead of defining mixin functions with anonymous classes.

export type Constructor<T = {}> = new (...args: any[]) => T;

/* turns A | B | C into A & B & C */
type UnionToIntersection<U> =
	(U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never

/* merges constructor types - self explanatory */
type MergeConstructorTypes<T extends Array<Constructor<any>>> =
  UnionToIntersection<InstanceType<T[number]>>;


export function Mixin<C1>(ctor1: Constructor<C1>): Constructor<C1>;
export function Mixin<C1, C2>(
  ctor1: Constructor<C1>,
  ctor2: Constructor<C2>,
): Constructor<C1 & C2>;
export function Mixin<C1, C2, C3>(
  ctor1: Constructor<C1>,
  ctor2: Constructor<C2>,
  ctor3: Constructor<C3>,
): Constructor<C1 & C2 & C3>;
export function Mixin<C1, C2, C3>(
  ctor1: Constructor<C1>,
  ctor2: Constructor<C2>,
  ctor3: Constructor<C3>,
): Constructor<C1 & C2 & C3>;
export function Mixin<C1, C2, C3, C4>(
  ctor1: Constructor<C1>,
  ctor2: Constructor<C2>,
  ctor3: Constructor<C3>,
  ctor4: Constructor<C4>,
): Constructor<C1 & C2 & C3 & C4>;
export function Mixin<C1, C2, C3, C4, C5>(
  ctor1: Constructor<C1>,
  ctor2: Constructor<C2>,
  ctor3: Constructor<C3>,
  ctor4: Constructor<C4>,
  ctor5: Constructor<C5>,
): Constructor<C1 & C2 & C3 & C4 & C5>;
export function Mixin() {
  const constructors = [].slice.call(arguments);
  return constructors.reduce((cls, mixin, idx) => {
    if (!idx) {
      return mixin;
    }
    const mixedClass = class extends mixin {
      constructor(...args: any[]) {
        super(...args);
        cls.call(this, args);
      }
    };
    Object.assign(mixedClass.prototype, cls.prototype, mixin.prototype);
    return mixedClass;
  });
}

Example:

class A<T> {
  a = 1;
  b = 3;

  genericFuncFromA(some: T) {

  }
}

class B {
  c = 2;

  duck() {

  }
}

class E {
  d = 4;
  quack() {
  }
}


class C<T> extends Mixin(A, B, E) {
  something(testing: T) {
  }

};

// retype the generic classes you care about
type _C<T> = C<T> & A<T>;

const d: _C<string> = new C<string>();

All the instance variables/methods come through great:
image

The generics are somewhat working...:
image

How can I get rid of this...:
image

Can't get out of the single base limitation either. Anyone might have ideas to how this could be achieved? Maybe something crazy could be done with proxies. Edit: Could be done with Symbol.hasInstance

image

@ShanonJackson
Copy link

its also easily possible to adapt the solution to use Objects instead of classes, or both.

@tannerntannern
Copy link

I implemented a solution to the generics problem in my ts-mixer library, but the solution can easily be adapted if you don't want to take on a (albeit, small) dependency.

Essentially, the trick is to use class decorators (which can't alter types) to apply the mixins "on the JavaScript side" in conjunction with interface merging to coerce the proper class type "on the TypeScript side." Because TypeScript is blind to changes made by class decorators, the interface merging will work without conflicts:

import {MixinDecorator} from 'ts-mixer';

// Some generic classes
class GenericClassA<T> {
	testA(input: T) {}
}
class GenericClassB<T> {
	testB(input: T) {}
}

// Class decorator/interface merging trick to create the generic mixed class
@MixinDecorator(GenericClassA, GenericClassB)
class Mixed<A, B> {
	newMethod(a: A, b: B) {}
}
interface Mixed<A, B> extends GenericClassA<A>, GenericClassB<B> {}

let mm = new Mixed<string, number>();
mm.testA('test');		// ok
// mm.testA(2);		        // will cause error

mm.testB(2);			// ok
// mm.testB('test');		// will cause error

More info on this exploit is available in the ts-mixer docs. If you prefer not to use my library, a potential MixinDecorator might look like this:

function MixinDecorator(...constructors) {
	return function<T, U extends T>(baseClass: T): U {
                // `Mixin` is assumed to come from one of the solutions above.
		return class Mixed extends Mixin(baseClass, ...constructors) {} as unknown as U;
	};
}

I'd love feedback if anyone has any. 🙂

@matthewadams
Copy link

@ahejlsberg, I am the author of @northscaler/mutrait, which enables stateful traits in JavaScript. I'm trying to use what this PR enables in order to implement a similar thing in TypeScript, but I'm still not sure if the language's type system permits it. I have a github repo demonstrating the issue at https://github.com/matthewadams/typescript-trait-test.

TL;DR: I'm basically trying to enable a simple way for folks to define traits & enable their classes to express them, like class Person extends trait(Nameable) { ... } (one trait) or class Person extends traits(Nameable).with(HasParents).with(HasChildren).apply() { ... } (multiple traits). The traits provide default implementations of isolated functionality, and the goal is that classes expressing traits can override methods provided by the trait.

This leverages the subclass factory pattern you describe at the top of this issue, but attempts to do so in a way that allows a class to override functionality. Your example illustrates that you can add functionality to a class, but you can't customize it easily because the subclass factory-created class extends the class receiving the functionality, instead of the other way around.

This works fine in JavaScript using @northscaler/mutrait, and we use it with great success. I'm just having some trouble describing the typing correctly. If you could have a look at my repo and tell me if I'm doing something wrong, or if TypeScript's typing system doesn't support what I'm trying to do, I'd appreciate it.

If you git clone https://github.com/matthewadams/typescript-trait-test && cd typescript-trait-test && npm install && npm test, you should see the following errors:

> typescript-trait-play@1.0.0 test /Users/matthewadams/dev/matthewadams/typescript-play/typescript-trait-play
> run-s build test:*


> typescript-trait-play@1.0.0 build /Users/matthewadams/dev/matthewadams/typescript-play/typescript-trait-play
> run-p build:*


> typescript-trait-play@1.0.0 build:main /Users/matthewadams/dev/matthewadams/typescript-play/typescript-trait-play
> tsc -p tsconfig.json


> typescript-trait-play@1.0.0 build:module /Users/matthewadams/dev/matthewadams/typescript-play/typescript-trait-play
> tsc -p tsconfig.module.json

src/index.ts:1:15 - error TS2307: Cannot find module './lib/async' or its corresponding type declarations.

1 export * from './lib/async';
                ~~~~~~~~~~~~~

src/index.ts:2:15 - error TS2307: Cannot find module './lib/number' or its corresponding type declarations.

2 export * from './lib/number';
                ~~~~~~~~~~~~~~

src/lib/traitify.spec.ts:11:19 - error TS2509: Base constructor return type 'S' is not an object type or intersection of object types with statically known members.

11     class extends superclass implements ITaggable {
                     ~~~~~~~~~~

src/lib/traitify.spec.ts:36:29 - error TS2345: Argument of type '<S>(superclass: Constructor<S>) => typeof (Anonymous class)' is not assignable to parameter of type 'Trait<unknown>'.
  Types of construct signatures are incompatible.
    Type 'new (...args: any[]) => Taggable<S>.(Anonymous class)' is not assignable to type 'new (...args: any[]) => S'.
      Type 'Taggable<S>.(Anonymous class)' is not assignable to type 'S'.
        'S' could be instantiated with an arbitrary type which could be unrelated to 'Taggable<S>.(Anonymous class)'.

36   class Point extends trait(Taggable) {
                               ~~~~~~~~

src/lib/traitify.spec.ts:50:9 - error TS2339: Property 'tag' does not exist on type 'Point'.

50   point.tag = 'hello';
           ~~~

src/lib/traitify.spec.ts:52:14 - error TS2339: Property 'tag' does not exist on type 'Point'.

52   t.is(point.tag, 'hello');
                ~~~

src/lib/traitify.spec.ts:53:24 - error TS2339: Property 'tag' does not exist on type 'Point'.

53   t.throws(() => point.tag = '');
                          ~~~

src/lib/traitify.spec.ts:59:43 - error TS2345: Argument of type '<S>(superclass: Constructor<S>) => typeof (Anonymous class)' is not assignable to parameter of type 'Trait<unknown>'.

59   class Sub extends superclass(Base).with(Taggable).apply() {
                                             ~~~~~~~~

src/lib/traitify.spec.ts:73:7 - error TS2339: Property 'tag' does not exist on type 'Sub'.

73   sub.tag = 'sub';
         ~~~

src/lib/traitify.spec.ts:75:12 - error TS2339: Property 'tag' does not exist on type 'Sub'.

75   t.is(sub.tag, 'sub');
              ~~~

src/lib/traitify.spec.ts:76:22 - error TS2339: Property 'tag' does not exist on type 'Sub'.

76   t.throws(() => sub.tag = 'throw');
                        ~~~


Found 11 errors.

Thanks in advance for this. Because we leverage traits in many of our projects, this is preventing us from using TypeScript more often.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet