1

Give the following code:

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

export const PositionedNodeMixin = <C extends Constructor>(superclass: C) => {
  return class NodePositionableAbilities extends superclass {
    pos: string = "";
  };
};

export const NodeChildrenMixin = <C extends Constructor>(superclass: C) => {
  const x = class NodeChildrenAbilities extends superclass {
    children: Record<string, boolean | undefined> = {};
  };
  return x;
};

class BaseNode {
  id: string;
  constructor() {
    this.id = Math.random().toString();
  }
}

That I can use like so:

const MixedClass = PositionedNodeMixin(NodeChildrenMixin(BaseNode));

const mixedClassInstance = new MixedClass();

console.log(mixedClassInstance.id);
console.log(mixedClassInstance.children);
console.log(mixedClassInstance.pos);

What would be the type that would allow us to write the code like so:

const MixedClass2 = mix(BaseNode, [PositionedNodeMixin, NodeChildrenMixin])

But will maintain the typesafety for the pos and children keys.

Vlad Nicula
  • 3,577
  • 6
  • 32
  • 50
  • 1
    Does [this approach](https://tsplay.dev/wOxpRN) meet your needs? If so I will write up an answer explaining; if not, what am I missing? – jcalz Jan 19 '23 at 18:46
  • yes it does! Thank you! Oh wow, this is exactly what I wanted. If you could kindly point me a resource that I can use to learn and be able to write such a thing I would be very grateful. – Vlad Nicula Jan 20 '23 at 06:34

1 Answers1

1

First let's augment your Constructor definition so that it is generic in the instance type:

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

That will make it easier to write the types in what follows.


My inclination here would be to define mix()'s call signature like this:

declare function mix<S extends object, M extends any[]>(
    superclass: Constructor<S>,
    mixins: [...{ [I in keyof M]: (c: Constructor<S>) => Constructor<M[I]> }
    ]
): Constructor<S & IntersectTuple<M>>

Let's walk through that. The function is generic in the type parameter S corresponding to the instance type of the superclass argument. So if superclass is of type Constructor<Super>, then S is just Super.

It's also generic in the type parameter M corresponding to the tuple of instance types of the classes returned by the elements of the the mixins argument. So if mixins is [FooMixin, BarMixin, BazMixin], which return Constructor<Foo>, Constructor<Bar> and Constructor<Baz>, respectively, then M would be just [Foo, Bar, Baz].

The type of mixins is a mapped tuple type over the numberlike indices I of M, where the Ith element of mixins is of type (c: Constructor<S>) => Constructor<M[I]>, as it accepts superclass as an input, and produce a constructor of the Ith element of M (of indexed access type M[I]).

By the way, that mapped type is wrapped in [.../] and is thus a variadic tuple type. That does very little to the type of mixins, but it gives the compiler a hint that we'd like it to infer a tuple type for M and not an unordered array type. This is somewhat important given the return type of mix().

That return type is Constructor<S & IntersectTuple<M>>, where IntersectTuple<M> is a utility type that evaluates to the intersection of all the elements of M. So if S is Super and M is [Foo, Bar, Baz], then the return type will be Constructor<Super & Foo & Bar & Baz>. That intersection means that the return type will construct instances of all those types at once. So it will construct things that are Super and Foo and Bar and Baz.

We need to define IntersectTuple; here's one way to do it:

type IntersectTuple<T extends any[]> =
    { [I in keyof T]: (x: T[I]) => void }[number] extends
    (x: infer R) => void ? R : never;    

It works via the rules for conditional type inference which say that if multiple inference candidates appear in a contravariant position (see Difference between Variance, Covariance, Contravariance and Bivariance in TypeScript ) then the resulting inferred type will be the intersection of all those candidates. (Also see Transform union type to intersection type for more about this general technique.)


Okay, let's test it!

const MixedClass2 = mix(BaseNode, [PositionedNodeMixin, NodeChildrenMixin]);
// const MixedClass2: Constructor<
//  BaseNode & 
//  PositionedNodeMixin<Constructor<{}>>.NodePositionableAbilities & 
//  NodeChildrenMixin<Constructor<{}>>.NodeChildrenAbilities
// >
const mixedClassInstance2 = new MixedClass2();
// const mixedClassInstance2: 
//   BaseNode & 
//   PositionedNodeMixin<Constructor<{}>>.NodePositionableAbilities & 
//   NodeChildrenMixin<Constructor<{}>>.NodeChildrenAbilities
mixedClassInstance2.id; // (property) BaseNode.id: string
mixedClassInstance2.children // (property) NodeChildrenAbilities.children: 
//   Record<string, boolean | undefined>
mixedClassInstance2.pos // (property) NodePositionableAbilities.pos: string

Looks good. The type of MixedClass2 is a constructor of the intersection of BaseNode and NodePositionAbilities and NodeChildrenAbilities, and so mixedClassInstance2 has all the expected properties coming from the expected places.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • This is an amazing answer and a lesson in TypeScript. Thank you kindly. I'll remember to check this answer and then visit your profile for more answers like this one. :) – Vlad Nicula Jan 21 '23 at 05:57