1

I'm trying to create a class that takes multiple constructors like so:

class MyClass<T> {
    constructor(ctors: (new (...args: any) => T)[]) {
        
    }
}

This works, but when the class is instantiated I want its type to be a union of all provided constructors. So if I do

const x = new MyClass([Foo, Bar]);

I want x to be of type MyClass<Foo | Bar>. Right now the returned type is MyClass<Bar> for some reason. (playground)

How can I tell TypeScript that it should infer multiple types? I realise you can instantiate explicitly using new MyClass<Foo | Bar>([Foo, Bar]) but my actual use case is a lot more complex so I'd really wish for the type to be inferred implicitly.

I might be able to get away with the following:

class MyClass<T extends (new (...args: any) => any)[]> {
    constructor(ctors: T) {
        
    }
}

Which results in MyClass<(typeof Foo | typeof Bar)[]> (playground). But it's not pretty, so I'd prefer to get a type of MyClass<Foo | Bar> if possible.

3
  • In the Playground I'm getting it inferred as MyClass<Foo>, not MyClass<Bar>. Commented Jan 8, 2022 at 15:10
  • @kaya3 Hmm that's weird. I wonder if a setting didn't get stored in the shared url. Or perhaps a race condition in TypeScript? I just double checked and I'm definitely getting MyClass<Bar>. Commented Jan 8, 2022 at 15:14
  • Perhaps it's non-deterministic due to your types Foo and Bar being structurally equivalent so Typescript actually doesn't care which one is which. After I changed the field in Bar from x to y so they are structurally distinct types, it gave me MyClass<Bar> instead. Commented Jan 8, 2022 at 15:16

2 Answers 2

1

Here's a solution which allows inference while still allowing the class's parameter T to be Foo | Bar instead of (typeof Foo | typeof Bar)[]: make a static factory method where the type parameter is like the latter, which returns a class instance where the type parameter is like the former.

I took the liberty of also making the constructor private so that it can't be called with an incorrect inferred type.

class MyClass<T> {
    static of<T extends new (...args: any) => any>(ctors: T[]): MyClass<InstanceType<T>> {
        return new MyClass(ctors);
    }

    private constructor(ctors: (new (...args: any) => T)[]) {
        // ...
    }
}

// inferred as MyClass<Foo | Bar>
const x = MyClass.of([Foo, Bar]);

Playground Link

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

4 Comments

Hah, crazy that this is necessary. In the generated JavaScript this would seem like a completely unnecessary method. I wish we could set the return types of constructors like we can with functions.
Yes, unfortunately sometimes writing a trivial function like this is the neatest solution to make Typescript happy. At least it is very likely to be inlined by the JIT (if it doesn't get inlined by something like Google Closure Compiler). That said, I am not 100% sure there isn't a neater solution for this specific problem.
If you do want to avoid the factory function, an option is to leave the class parameter like T = typeof Foo | typeof Bar but declare type IMyClass<T> = MyClass<new (...args: any) => T>, and then use IMyClass<Foo | Bar> as your type annotation instead of MyClass<typeof Foo | typeof Bar>.
And then create new instances like const x : IMyClass<Foo | Bar> = new MyClass([Foo, Bar]);? That's still pretty verbose. I think I'll end up using a combination of a factory function and a type that creates the generic argument from @Mysak0CZ's answer.
1

There are two different parts to this problem:

  1. Typescript doesn't treat types as same/different by name, but by content. Because in your example classes Foo and Bar are exactly the same, TS treats them as such. To avoid this simply add a property to one of them but not to the other one (making them incompatible).
class Foo {
    fooProperty: undefined;
    constructor(x: number) {}
}
class Bar {
    barProperty: undefined;
    constructor(x: string) {}
}
  1. Once you do that, you might get error in the following code:
const x = new MyClass([Foo, Bar]);

Because TS tries to infer the type, but may fail to do so automatically. In that case you want to specify the template type manually:

const x = new MyClass<Foo | Bar>([Foo, Bar]);

As you however said your usecase is more complex, you can use some "magic" by first storing all constructors in an array and then use infer to get correct union of tyes:

const constructorList = [Bar, Foo];
type GetConstructedTypes<T extends (new (...args: any) => any)[]> = T extends (new (...args: any) => infer U)[] ? U : never;
const x = new MyClass<GetConstructedTypes<typeof constructorList>>(constructorList);

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.