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