A function that takes a constructor that extends a base type and returns the same constructor minus the first argument

Viewed 91

I am working on converting an old JS codebase to TypeScript. I have a rather advanced scenario and I can't figure out how to assign types correctly. I'd like to avoid resorting to the any type if possible.

To start, I have a base class that establishes a common context and then several dozen classes that extend this base class:

class Context {}

class ContextDependent {
  protected readonly context:Context;

  constructor(context:Context) {
    this.context = context;
  }
}

class MyClass extends ContextDependent {
  private myParam:string;

  constructor(context:Context, myParam:string) {
    super(context);
    this.myParam = myParam;
  }
}

(For what it's worth, by convention all classes that extend this base class take Context as their first argument)

I need to expose some of these classes to a public-facing API, but the context argument is not accessible via the public API, so I expose them by binding the first constructor argument:

function bindClass(context, Class) {
  // magic happens

  // This is a very naive solution given as a simple example.
  // The actual logic is much more complex (so as to preserve instanceof checks, etc)
  return Class.bind({}, context);
}

function getPublicApi(context) {
  return {
    MyClass: bindClass(context, MyClass),
  }
}

This system has worked extremely well, but I'm now trying to figure out how to convert this to TypeScript, if it's even possible.

I've tried a couple iterations and the closest thing I've come up with is:

function bindClass<T extends ContextDependent>(context:Context, Class:new (context:Context) => T):new () => T {
  // magic happens
}

But of course this does not work. While the type declaration is accepted, the constraint rejects sub-classes of ContextDependent that take more than the context argument:

bindClass(context, MyClass);

/*
Error:

Argument of type 'typeof MyClass' is not assignable to parameter of type 'new (context: Context) => MyClass'.
  Types of construct signatures are incompatible.
    Type 'new (context: Context, myParam: string) => MyClass' is not assignable to type 'new (context: Context) => MyClass'.ts(2345)
*/

I understand why this isn't working, but I can't find a solution that does quite what I'm looking for. Is there some way to include the additional arguments dynamically and then include them, something like:

function bindClass<T extends ContextDependent>(context:Context, Class:new (context:Context, ...args:unknown[]) => T):new (...args:[unknown]) => T {
  // magic happens
}

/*
Error:

Argument of type 'typeof MyClass' is not assignable to parameter of type 'new (context: Context, ...args: unknown[]) => MyClass'.
  Types of construct signatures are incompatible.
    Type 'new (context: Context, myParam: string) => MyClass' is not assignable to type 'new (context: Context, ...args: unknown[]) => MyClass'.
      Types of parameters 'myParam' and 'args' are incompatible.
        Type 'unknown' is not assignable to type 'string'.ts(2345)
*/
2 Answers

You can use generic rest parameters to express the tuple of parameters that come after the first Context constructor parameter of Class, like this:

function bindClass<C extends Context, A extends any[], T extends ContextDependent>(
  context: C,
  Class: new (context: C, ...rest: A) => T
): new (...args: A) => T {
  return Class.bind({}, context);
}

This compiles without error, and you can verify that it allows getPublicApi to be typed the way you want (as long as you annotate that its context parameter is a Context):

function getPublicApi(context: Context) {
  return {
    MyClass: bindClass(context, MyClass),
  }
}
/* function getPublicApi(context: Context): {
    MyClass: new (myParam: string) => MyClass;
} */

Playground link to code

I'm not sure that there is a truly dynamic solution, but you can use overloads and generics to allow for additional arguments with type matching.

The downside is that you have to include an overload for each additional argument, which means you can't allow for an indefinite number of arguments.

However, it's not difficult to provide enough overloads to satisfy the needs for your own codebase:

function bindClass<T extends ContextDependent>(context:Context, Class:new (context:Context) => T):new () => T;
function bindClass<T extends ContextDependent, T1>(context:Context, Class:new (context:Context, ...args:[T1]) => T):new (...args:[T1]) => T;
function bindClass<T extends ContextDependent, T1, T2>(context:Context, Class:new (context:Context, ...args:[T1, T2]) => T):new (...args:[T1, T2]) => T;
function bindClass<T extends ContextDependent, T1, T2, T3>(context:Context, Class:new (context:Context, ...args:[T1, T2, T3]) => T):new (...args:[T1, T2, T3]) => T;
function bindClass<T extends ContextDependent, T1, T2, T3>(context:Context, Class:new (context:Context, ...args:[T1?, T2?, T3?]) => T):new (...args:[T1?, T2?, T3?]) => T {
  // magic happens
  // This is a very naive solution given as a simple example.
  // The actual logic in bindClass is much more complex (so as to preserve instanceof checks, etc)
  return Class.bind({}, context);
}

type PublicApi = {
  MyClass: new (myParam:string)=>MyClass,
  MyOtherClass: new (myParam:string, myOtherParam:number)=>MyOtherClass,
};

function getPublicApi(context:Context):PublicApi {
  return {
    MyClass: bindClass(context, MyClass),
    MyOtherClass: bindClass(context, MyOtherClass),
  }
}

Notice that the strict type-checking works beautifully, even in VS Intellisense:

screenshot of intellisense in Visual Studio Code that demonstrates matching the argument types in both the class constructor provided as an input and the new class constructor which is returned

For further information see:

Related