Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Using ConstructorParameters to extend a generic class?

Say I have the following generic class, which has two fields: one a string, and the other a totally arbitrary generic--whatever I want it to be at instantiation:

class Parent<T> {
    stringField: String
    genericField: T;

    constructor(stringField: String, genericField: T) {
        this.stringField = stringField;
        this.genericField = genericField;
    }
}

Instantiating this works as expected, e.g. new Parent("hello", "world").genericField provides intellisense for a string, new Parent("hello", 10000).genericField provides intellisense for a number, etc.

Now let's say that I want to extend Parent. This new subclass constructor should take one additional parameter not present in the Parent constructor, in addition to both the values of stringField and genericField.

However, I don't want to just copy-paste the Parent parameters, as that's not scalable if I ever need to change Parent. So instead, I'd like to use the ConstructorParameters utility type to infer those redundant parameters and pass them to the super call automatically, similar to this:

class Child<G> extends Parent<G> {
    numberField: Number;

    constructor(numberField: Number, ...params:ConstructorParameters<typeof Parent>) {
        super(...params);
        this.numberField = numberField;
    }
}

However, this does not work as expected. The above call to super in the Child class produces the following compiler error: Argument of type 'unknown' is not assignable to parameter of type 'G'. 'G' could be instantiated with an arbitrary type which could be unrelated to 'unknown'.

And indeed, writing new Child(22, "hello", "world").genericField does not provide intellisense for a string, as genericField is always of type unknown here, when I want it to be whatever type I pass to it, just as when I instantiate Parent.

like image 799
user3781737 Avatar asked Jul 16 '21 21:07

user3781737


2 Answers

I don't know if there is an alternative strategy or feature of Typescript that can be leveraged, but since I'm short on time I'll just answer here why your approach doesn't work.

In your code the term Parent in ConstructorParameters<typeof Parent> is not constrained by the G type parameter. Thus its implicit type is Parent<unknown>.

The G type parameter declared in Child<G> only constrains two things in your code:

  1. the type the child class extends, i.e. Parent<G>

  2. the expected args to the super call in the ctor. This follows from #1. If you hover over super in your IDE it will show:

    constructor Parent<G>(stringField: String, genericField: G): Parent<G>
    

To further confirm/understand what is going on, change class Parent<T> to class Parent<T extends number> and see how the error changes. What I said above should be totally clear now.

The obvious way to fix it would be to use the G type param to constrain the ConstructorParameters, e.g.:

class Child<G> extends Parent<G> {
    numberField: Number;

    constructor(numberField: Number, ...params:ConstructorParameters<typeof Parent<G>>) {
        super(...params);
        this.numberField = numberField;
    }
}

But Typescript doesn't support this syntax, maybe not even the semantics.

Maybe there is a way to use infer or defined a custom ConstructorParameters that is tied to Parent, but I don't have the time right now to play with that.

This is an interesting problem. I would think TS would have a solution for this, or would want to have a solution. I would submit an Issue to the TS team to support

ConstructorParameters<typeof Parent<G>>

You might get a "Good idea!" response, or a solution to your problem (point to this SO question). If you do submit an Issue, please post a link to it in your question.

Hopefully someone smarter than me will see this and swoop in with a solution.

Good luck.

like image 121
Inigo Avatar answered Sep 22 '22 09:09

Inigo


Created my own utility type to solve this in TS version 4.3.5. I use it in place of the regular ConstructorParameters when dealing with generics, and it seems pretty scalable, though be sure to read the explanation section if you want to be sure. Here it is as a file with its helpers, along with a couple of example playgrounds:

GenericConstructorParameters.ts

// helpers
type IfAny<T, Y, N> = 0 extends (1 & T) ? Y : N; 
type IsUnknown<T> = unknown extends T ? IfAny<T, never, true> : never;
type UnknownReplacer<T, K> = K extends [infer WithThis, ...infer WithRest] ? T extends [infer ReplaceThis, ...infer ReplaceRest] ? IsUnknown<ReplaceThis> extends never ? [ReplaceThis, ...UnknownReplacer<ReplaceRest, K>] : [WithThis, ...UnknownReplacer<ReplaceRest, WithRest>] : []: T


// GenericConstructorParameters: Takes two arguments
// Arg 1. a constructor
// Arg 2. a tuple of types (one type for each generic in the constructor)
type GenericConstructorParameters<T extends abstract new (...args: any) => any, K> = UnknownReplacer<ConstructorParameters<T>, K>

Here it is in action with the classes in my original question: Playground Link

And here it is if the Parent class takes multiple generics: Playground Link



Why it works

GenericConstructorParameters takes two arguments. It works by calling ConstructorParameters on its first argument (a constructor, just like the first argument of ConstructorParameters) to get a tuple of constructor argument types. Because generics become unknown when extracted by ConstructorParameters, we then replace each unknown type in the resulting tuple with a type provided by our second argument (a tuple of replacement types).

Note that while I've only ever seen unknown replace a generic as a result of calling ConstructorParameters on a generic class, I don't know if that's guaranteed in every scenario. So I'm hoping someone else can verify that.

If that is correct, then reliably determining what is or isn't truly unknown is the next thing to do. I created the IsUnknown utility type (expanded below for readability) to do that, with help from the IfAny utility which I got from this SO answer. However, this is another instance of not being certain this works in every single scenario. If it does though, then that should mean this is scalable and should work no matter what other types my super class uses, including any types.

type IsUnknown<T> =
  unknown extends T         // unknown only extends either itself or any (I think)
    ? IfAny<T, never, true> // so if we can narrow it down, just check if it is any
    : never;

I created the UnknownReplacer utility (expanded below for readability) to do most of the leg work. I figured out how to do recursion on variadic tuples thanks to this SO answer, and through that I'm able to replace unknowns from the constructor parameters with whatever the next unused type is in our replacements tuple.

type UnknownReplacer<T, K> =
  K extends [infer WithThis, ...infer WithRest]                   // if K is a populated tuple of types (i.e. one for each unknown to replace)
    ? T extends [infer ReplaceThis, ...infer ReplaceRest]         // ...then if T is a populated tuple of types (i.e. from our constructor arguments)
      ? IsUnknown<ReplaceThis> extends never                      // ......then check if the first type in T is NOT unknown
        ? [ReplaceThis, ...UnknownReplacer<ReplaceRest, K>]       // .........and if not unknown, return the first type from T with a recursive call on the remaining args from T
        : [WithThis, ...UnknownReplacer<ReplaceRest, WithRest>]   // .........but if it is unknown, return the first type from K with a recursive call on the remaining args from T and K 
      : []                                                        // ......but if T is empty (or invalid), return an empty tuple as we've run out of things to check
    : T                                                           // ...but if K is empty (or invalid), return T as there's nothing we can use to replace anyway

Finally the GenericConstructorParameters type just sort of wraps things together nicely.

I don't know how niche this use case is, or if someone's solved this before, but I'm hoping this is (actually correct and) able to help someone else out if they come across the same problem.

Looking for feedback!

like image 34
user3781737 Avatar answered Sep 24 '22 09:09

user3781737