In case anyone is interested, here's how I arrived at the solution.
Switching the type parameters
The challenge of this task was to extract the return type not only from a single function but from an array of functions as well. I needed a conditional type that would accept either a single TestPlugin or an array of those, and produce two different results depending on what was provided:
- If a single
TestPlugin was provided, extract its return type.
- If
TestPlugin[] was provided, create an intersection of all return types.
Since the conditional type I had in mind would accept a union of TestPlugin | TestPlugin[], I had to declare one in the scope of the plugin method. Let's switch the definition from this:
static plugin<T extends TestPlugin>(plugin: T | T[])
to this:
static plugin<T extends (TestPlugin | TestPlugin[])>(plugin: T)
Now we have a T we can work with.
Building the conditional type
The final version of the conditional type looks like this:
type ReturnTypeOf<T extends AnyFunction | AnyFunction[]> =
T extends AnyFunction
? ReturnType<T>
: T extends AnyFunction[]
? UnionToIntersection<ReturnType<T[number]>>
: never
To pull it off, I need two helper types. The first is AnyFunction. This makes ReturnTypeOf work with any functions, not just TestPlugin, but we could have used TestPlugin just as well if we don't plan on reusing ReturnTypeOf in other places.
type AnyFunction = (...args: any) => any;
Another type is the famous transformation by the great @jcalz. If T is an array of functions, and arrays are indexed by numbers, then T[number] is a union of all members of that array. It's called a lookup type.
We could call ReturnType on an array of functions (and get a union of their return types back), but that's not what we want. We want their intersection, not their union. UnionToIntersection will do that for us.
/**
* @author https://stackoverflow.com/users/2887218/jcalz
* @see https://stackoverflow.com/a/50375286/10325032
*/
type UnionToIntersection<Union> =
(Union extends Unrestricted
? (argument: Union) => void
: never
) extends (argument: infer Intersection) => void // tslint:disable-line: no-unused
? Intersection
: never;
Replacing ReturnType with our custom ReturnTypeOf
When we inspect the type of T in the scope of Test.plugin, we notice it's always either a plugin or an array of plugins. Even the type guard (Array.isArray(plugin)) didn't help TypeScript discriminate that union. And since the built-in ReturnType cannot accept arrays, we need to replace both instances of ReturnType with our custom ReturnTypeOf.
Final solution
type ApiExtension = { [key: string]: any }
type TestPlugin = (instance: Test) => ApiExtension;
type Constructor<T> = new (...args: any[]) => T;
/**
* @author https://stackoverflow.com/users/2887218/jcalz
* @see https://stackoverflow.com/a/50375286/10325032
*/
type UnionToIntersection<Union> =
(Union extends any
? (argument: Union) => void
: never
) extends (argument: infer Intersection) => void // tslint:disable-line: no-unused
? Intersection
: never;
type AnyFunction = (...args: any) => any;
type ReturnTypeOf<T extends AnyFunction | AnyFunction[]> =
T extends AnyFunction
? ReturnType<T>
: T extends AnyFunction[]
? UnionToIntersection<ReturnType<T[number]>>
: never
class Test {
static plugins: TestPlugin[] = [];
static plugin<T extends TestPlugin | TestPlugin[]>(plugin: T) {
const currentPlugins = this.plugins;
class NewTest extends this {
static plugins = currentPlugins.concat(plugin);
}
if (Array.isArray(plugin)) {
type Extension = ReturnTypeOf<T>
return NewTest as typeof NewTest & Constructor<Extension>;
}
type Extension = ReturnTypeOf<T>
return NewTest as typeof NewTest & Constructor<Extension>;
}
constructor() {
// apply plugins
// https://stackoverflow.com/a/16345172
const classConstructor = this.constructor as typeof Test;
classConstructor.plugins.forEach(plugin => {
Object.assign(this, plugin(this))
});
}
}
const FooPlugin = (test: Test): { foo(): 'foo' } => {
console.log('plugin evalutes')
return {
foo: () => 'foo'
}
}
const BarPlugin = (test: Test): { bar(): 'bar' } => {
console.log('plugin evalutes')
return {
bar: () => 'bar'
}
}
const FooTest = Test.plugin(FooPlugin)
const fooTest = new FooTest()
fooTest.foo()
const FooBarTest = Test.plugin([FooPlugin, BarPlugin])
const fooBarTest = new FooBarTest()
fooBarTest.foo()
fooBarTest.bar()
TypeScript Playground