7

In TypeScript, I've defined a helper function called create that takes a constructor and the constructor's typed arguments to create a new instance of a class:

function create<Ctor extends new (...args: any[]) => any, R extends InstanceType<Ctor>>(
  ctor: Ctor,
  ...args: ConstructorParameters<Ctor>
): R {
  return new ctor(...args)
}

This works for simple classes like so:

class Bar  {
  constructor(param: number) { }
}

const bar1 = create<typeof Bar, Bar>(Bar, 1);

// Compile error: Argument of type '"string"' is not assignable to parameter of type 'number'.ts(2345)
const bar2 = create<typeof Bar, Bar>(Bar, 'string');

However, if I have a generic class, I can't get TypeScript to perform the proper type checking on create:

class Foo<T>  {
  constructor(param: T) { }
}

// Ok
const foo1 = create<typeof Foo, Foo<number>>(Foo, 1);

// This should be an error but is not
const foo2 = create<typeof Foo, Foo<number>>(Foo, { not: 'a number' })

The root cause is that signature of create in the second case takes an unknown parameter:

enter image description here

Main question

Is it possible to explicitly write out the type of the generic class constructor in the call to create without inlining the type itself and while still preserving the signature of create?

Attempts

My first thought was:

create<typeof Foo<number>, Foo<number>>(Foo, { not: 'a number' })

But that is invalid code.

So far, the best workaround I've found is to use a temporary class to explicitly capture the constructor type:

class Temp extends Foo<number> { }

// This correctly generates an error
create<typeof Temp, Foo<number>>(Foo, { not: 'a number' });

Can this be done in a more elegant way?

5
  • Could you do this instead? It uses higher order function inference in TS3.4+ but the type parameters differ. Commented Dec 18, 2019 at 2:23
  • If it meets your needs I can write it up as an answer. Commented Dec 18, 2019 at 2:23
  • Thanks for taking a look. I'm trying to avoid explicitly typing out the constructor's argument types. The argument list can get pretty long in real code and explicitly typing it out in multiple places would make refactoring difficult Commented Dec 18, 2019 at 18:06
  • 1
    I'm not sure why you want to specify the generics in the function call at all. If you expect the function to return a Foo<number> then you can annotate that and see an error if you don't pass the right thing, like this. Commented Dec 18, 2019 at 18:16
  • There's also generic currying but that is probably less useful than just the annotation from my last comment. Commented Dec 18, 2019 at 18:21

1 Answer 1

6
+500

The create() function should probably be generic in the argument list A to the constructor, as well as the constructed instance type R. This takes advantage of higher order type inference that was introduced in TypeScript 3.4.

function create<A extends any[], R>(
    ctor: new (...args: A) => R,
    ...args: A
): R {
    return new ctor(...args)
}

From there, the question is how to specify the types so that you will ask for a Foo<number> and get some sort of error if your parameter is not correct. One way to do this is to manually specify the generic parameters:

// Specify generics
const fooSpecifiedGood = create<[number], Foo<number>>(Foo, 123); // okay
const fooSpecifiedBad = create<[number], Foo<number>>(Foo, { not: 'a number' }); // error

That works. If, as you mentioned, your constructor parameter list is too long or complicated to write out, then I'd suggest just annotating the type of a variable to which you save the returned instance, like this:

// Just annotate the variable you're writing to
const fooAnnotatedGood: Foo<number> = create(Foo, 123); // okay
const fooAnnotatedBad: Foo<number> = create(Foo, { not: 'a number' }); // error

Now possibly you'd prefer being able to call create<Foo<number>>(Foo, 123) where you manually specify the R parameter as Foo<number>, but let the compiler infer the A parameter. Unfortunately TypeScript doesn't currently support such partial type parameter inference. For any given generic function call, either you can manually specify all the type parameters, or you can let the compiler infer all the type parameters, and that's basically it (type parameter defaults complicate the story a bit but it still doesn't give you this ability). One workaround for this is to use currying to take a single function like <A, R>()=>... and turn it into a higher order function like <A>()=><R>()=>.... Applying this technique to create() gives you this:

// Or get a curried creator function that lets you use specify the instance type
// while allowing the compiler to infer the compiler args
const curriedCreate = <R>() =>
    <A extends any[]>(ctor: new (...args: A) => R, ...args: A) => new ctor(...args);

const fooCurriedGood = curriedCreate<Foo<number>>()(Foo, 123); // okay
const fooCurriedBad = curriedCreate<Foo<number>>()(Foo, { not: 'a number' }); // error

So those are the main options I see. Personally I'd use the fooAnnotated approach, since it is analogous to const oops: Foo<number> = new Foo({not: 'a number'}). The error isn't in calling the Foo constructor; it's in assuming that the result is a Foo<number>. Anyway, hope one of those helps you. Good luck!

Playground link to code

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.