9

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.

user3781737
  • 932
  • 7
  • 17
  • 1
    Please provide a complete example -- i.e. actually instantiate Child and show what you expect. Also you naming is VERY confusing. Why are `otherStaticType`, `staticType ` and `genType` named `____type`? – Inigo Jul 20 '21 at 05:05
  • 1
    @Inigo thanks for letting me know my question wasn't clear. I just edited it significantly with instantiations and better named fields to hopefully provide a lot more clarity. – user3781737 Jul 20 '21 at 13:31
  • @Inigo, I've tried a bunch of things too and none of them work. If you can consider a possible change of approach I would like to chat about that but that requires knowing an exact business case – Oleksandr Kovalenko Jul 21 '21 at 11:05
  • @OleksandrKovalenko thanks for looking into this. I just posted my own answer which seems to work, but I'm not 100% sure about due to some of my uncertainty with certain TS types. If you have the time, please let me know if my assumptions hold true! – user3781737 Jul 21 '21 at 18:49

3 Answers3

5

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.

Inigo
  • 12,186
  • 5
  • 41
  • 70
  • 1
    Thanks a lot for this answer. This helped a lot in reframing this problem my head, and after playing around with this (for way, way too long), I think I came up with a solution which I just posted below. I would definitely appreciate some feedback on whether or not some of my assumptions hold true, any problems this might cause, etc. – user3781737 Jul 21 '21 at 18:53
4

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!

user3781737
  • 932
  • 7
  • 17
  • 1
    I believe your solution is ok. You have my upvote – captain-yossarian from Ukraine Jul 22 '21 at 08:31
  • I'm glad you found something that works! I don't have time right now to digest what you did, but from a cursory look it's complicated! Seems to me your original need isn't that esoteric, and should be more easily supported by Typescript. Can you please submit an issue with with the Typescript folks? Pointing to this SO question and your answer (and my answer if it helps)? – Inigo Jul 27 '21 at 22:54
  • @Inigo The answer is only long because I explain why it probably works, but the actual solution is just another utility type, though I'd like to make the separation between that and the explanation more clear. But now instead of `ConstructorParameters`, I can now use my own `ConstructorParametersGeneric` type instead. And since I guess I was able to create this with proper TS, I'm not sure this would be worth a full TS issue. – user3781737 Jul 28 '21 at 02:35
0

This works as well and is a bit more manageable

type GenericConstructorParameters<T> = ConstructorParameters<new ( ...args: any[] ) => T>
jgard
  • 11
  • 1