Jespertheend
Jespertheend

Reputation: 2280

How to allow multiple inferred constructors as arguments in TypeScript?

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.

Upvotes: 1

Views: 277

Answers (2)

kaya3
kaya3

Reputation: 51063

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

Upvotes: 1

Mysak0CZ
Mysak0CZ

Reputation: 964

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);

Upvotes: 1

Related Questions