Reputation: 33364
Can I define a return type for the below Mixin()
function that would resolve to an intersection type of whatever the parameter types happen to be?
function Mixin(...classRefs: any[]) {
return merge(class {}, ...classRefs);
}
function merge(derived: any, ...classRefs: any[]) {
classRefs.forEach(classRef => {
Object.getOwnPropertyNames(classRef.prototype).forEach(name => {
if (name !== 'constructor') {
Object.defineProperty(
derived.prototype,
name,
Object.getOwnPropertyDescriptor(classRef.prototype, name) as PropertyDescriptor
);
}
});
});
return derived;
}
class Foo {
foo() {}
}
class Bar {
bar() {}
}
class Baz {
baz() {
console.log('baz');
}
}
class MyClass extends Mixin(Foo, Bar, Baz) {}
const my = new MyClass();
my.baz();
Without the spread operator it would be reasonably straightforward with generics. How can I ensure the type system matches what actually happens?
Edit: I revised the example to match the mixin function I was actually using.
Edit: Two follow-up questions:
...classRefs
extend some common base class? Say I create abstract class Mergable {}
and then only descendents can be passed to merge
.function Mixin<T extends ClassType, R extends T[]>(...classRefs: [...R]):
new (...args: any[]) => UnionToIntersection<InstanceType<[...R][number]>> {
return merge(class { }, ...classRefs);
}
function merge(derived: ClassType, ...classRefs: ClassType[]) {
classRefs.forEach(classRef => {
Object.getOwnPropertyNames(classRef).forEach(name => {
const descriptor = Object.getOwnPropertyDescriptor(classRef, name);
if (name !== 'name' && name !== 'prototype' && name !== 'length' && descriptor) {
Object.defineProperty(
derived,
name,
descriptor
)
}
});
// Static Properties
Object.getOwnPropertyNames(classRef).forEach(name => {
// you can get rid of type casting in this way
const descriptor = Object.getOwnPropertyDescriptor(classRef.prototype, name)
if (name !== 'name' && name !== 'prototype' && name !== 'length' && descriptor) {
Object.defineProperty(
derived.prototype,
name,
descriptor
);
}
});
// Instance Properties
Object.getOwnPropertyNames(classRef.prototype).forEach(name => {
// you can get rid of type casting in this way
const descriptor = Object.getOwnPropertyDescriptor(classRef.prototype, name)
if (name !== 'constructor' && descriptor) {
Object.defineProperty(
derived.prototype,
name,
descriptor
);
}
});
});
return derived;
}
Upvotes: 1
Views: 471
Reputation: 33111
Please let me know if it works for you:
// credits goes to @jcalz
type UnionToIntersection<U> =
(U extends any ? (k: U) => void : never) extends (
k: infer I
) => void
? I
: never;
type Infer<T> =
T extends infer R
? R extends new (...args: any) => any
? InstanceType<R>
: never
: never;
type ClassType = new (...args: any[]) => any;
function Mixin<T extends ClassType, R extends T[]>(...classRefs: [...R]):
new (...args: any[]) => UnionToIntersection<InstanceType<[...R][number]>> {
return merge(class { }, ...classRefs);
}
function merge(derived: ClassType, ...classRefs: ClassType[]) {
classRefs.forEach(classRef => {
Object.getOwnPropertyNames(classRef.prototype).forEach(name => {
// you can get rid of type casting in this way
const descriptor = Object.getOwnPropertyDescriptor(classRef.prototype, name)
if (name !== 'constructor' && descriptor) {
Object.defineProperty(
derived.prototype,
name,
descriptor
);
}
});
});
return derived;
}
class Foo {
foo() { }
}
class Bar {
bar() { }
}
class Baz {
baz() {
console.log('baz');
}
}
class MyClass extends Mixin(Foo, Bar, Baz) { }
const my = new MyClass();
my.foo() // ok
my.bar() // ok
my.baz(); // ok
Infer type
type Infer<T> = /*1*/ T extends infer R ? /*2*/ R extends new (...args: any) => any ? /*3*/ InstanceType<R> : never : never;
InstanceType<R>
Mixin
function Mixin</*1*/T extends ClassType, /*2*/R extends T[]>(...classRefs: /*3*/[...R]): new (...args: any[]) =>/*4*/ UnionToIntersection<InstanceType<[...R][number]>> {
return merge(class { }, ...classRefs);
}
T
generic as a base type for all arguments.R
serves for argument type. As You see it is an array of T
, in other words array of classes[...R]
instead of T[]
. Because T[]
, will expect that all arguments have exactly same type, which is not useful in our case.[...R][number]>
means just union of all elements in the array. Example [1,2,3][number] === 1|2|3
. InstanceType<[...R][number]>
means InstanceType|InstanceType|InstanceType. And UnionToIntersection
just merges all unions into one type.UPDATE 3
How can I ensure all classes in ...classRefs extend some common base class? Say I create abstract class Mergable {} and then only descendents can be passed to merge.
Just replace Mixin
with next one:
function Mixin<T extends ClassType, R extends T[]>(...classRefs: [T, ...R]):
new (...args: any[]) => UnionToIntersection<InstanceType<[...R][number]>> {
return merge(class { }, ...classRefs);
}
Here I have explicitly defined first element with T
generic type. Now, TS knows that R
should extend array of T
.
Try to use Bar
class now without extending from Foo
. You will receive an error
Upvotes: 1
Reputation: 330456
You can write a type function which takes a tuple and produces an intersection of all its elements. The obvious way to do this is with recursive conditional types:
// obvious version:
type IntersectAll<T extends readonly any[]> =
T extends [infer F, ...infer R] ? F & IntersectAll<R> : unknown;
That works, but hits recursion limits if your tuples are longer than ~20 elements. There's a less obvious version that uses contravariance in conditional type inference:
// less obvious version:
type UnionToIntersection<U> =
(U extends any ? (k: U) => void : never) extends
((k: infer I) => void) ? I : never
type IntersectAll<T extends readonly any[]> = Extract<
UnionToIntersection<
{ [K in keyof T]: [T[K]] }[number]
>, readonly any[]>[number]
Both versions should work in what follows:
You want merge()
to take at least one class constructor of zero arguments (right? I don't want to worry about what happens if your constructors need arguments to construct them), and produce a new class constructor that produces the intersection of all the instance types of the original constructors.
If so, this is the typing I would give:
function merge<T, U extends any[]>(
derived: new () => T,
...classRefs: { [I in keyof U]: new () => U[I] }
) {
classRefs.forEach(classRef => {
Object.getOwnPropertyNames(classRef.prototype).forEach(name => {
if (name !== 'constructor') {
Object.defineProperty(
derived.prototype,
name,
Object.getOwnPropertyDescriptor(
classRef.prototype, name) as PropertyDescriptor
);
}
});
});
return derived as new () => (T & IntersectAll<U>);
}
Essentially, this function is generic in T
, the instance type of the first constructor; and U
, the tuple of instance types from the rest of the constructors. So derived
has a construct signature new () => T
, while classRefs
has a tuple of construct signatures, represented by the mapped tuple where you take the elements of U
and wrap it with new () => ...
. The return type is new() => (T & IntersectAll<U>)
, a constructor that produces intersections of all the other constructors.
And Mixin()
is similarly generic:
function Mixin<T extends any[]>(
...classRefs: { [I in keyof T]: new () => T[I] }
) {
return merge<unknown, T>(class { }, ...classRefs);
}
/* function Mixin<T extends any[]>(
...classRefs: { [I in keyof T]: new () => T[I]; }
): new () => IntersectAll<T> */
You can verify that it works as expected:
class Foo { foo() { return 6; } }
class Bar { bar() { return "abc" } }
class Baz { baz() { console.log('baz'); } }
class MyClass extends Mixin(Foo, Bar, Baz) { }
const my = new MyClass();
console.log(my.foo().toFixed(2)); // "6.00"
console.log(my.bar().toUpperCase()); // "ABC"
my.baz(); // "baz"
There you go. I assume there are edge cases here, since playing around with prototypes manually can do funny things. And the intersection isn't exactly right if you mix together classes with conflicting types for the same property or method names. And with class constructor arguments, of course. Such edge cases might be addressable, with additional complexity... but I'd consider any of that to be out of scope here.
Upvotes: 1