0

This is a follow up question to Extend class with Plugin Architecture

I have a class Test with a static method .plugin which takes a function. That function can run arbitrary code and extend Tests API.

const FooTest = Test.plugin(FooPlugin)
const fooTest = new FooTest()
fooTest.foo()

FooPlugin returns an object { foo(): 'foo' } which is combined with the prototype of Test when cretaing FooTest. That part is working.

Now I would like the static .plugin() method to also accept an array of Plugins, so I don't need to create a new Class each time I call the .plugin() method, but only once. The returned class should reduce the array of return types into a single object and merge that with Tests prototype.

Here is the resulting Code I'd like to make possible

const FooBarTest = Test.plugin([FooPlugin, BarPlugin])
const fooBarTest = new FooBarTest()
fooBarTest.foo()
fooBarTest.bar()

Is that possible at all? I've created a TypeScript Playground, lines 15-16 require changes to make it all possible. Thanks for your help!

Gregor
  • 2,325
  • 17
  • 27

2 Answers2

3

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

Karol Majewski
  • 23,596
  • 8
  • 44
  • 53
  • 1
    Good job! If the `plugin` function definition has to remain the same like in above question, you could even use the default `ReturnType` directly together with `UnionToIntersection`. [sample](https://stackblitz.com/edit/typescript-knarwj) (playground URL didn't fit here) – ford04 Oct 29 '19 at 21:35
  • Correct! For TypeScript, both cases are the same so there is no need to create two branches. Good catch. – Karol Majewski Oct 29 '19 at 21:45
0

Karol from WrocTypeScript figured it out: Playground

Gregor
  • 2,325
  • 17
  • 27