Search code examples
typescripttemplatesvariadic-templates

Typescript - How to infer correct subclass-type from dynamically created object


This is a follow up of this question.

What I am trying to achive is: passing multiple sub-classes to a function is there a way to return an object that as a "schema" with the types of sub-classes passed? So that i can preserve all intellisense features.

Below my failed attempt.

class Component {
    get name(): string {
        return this.constructor.name
    }
}

class Direction extends Component {
    x: number
    y: number

    constructor(x: number = 0, y: number = 0) {
        super()
        this.x = x
        this.y = y
    }
}

class Rectangle extends Component {
    x: number
    y: number
    w: number
    h: number

    constructor(x: number, y: number, w: number, h: number) {
        super()
        this.x = x
        this.y = y
        this.w = w
        this.h = h
    }
}

class Entity {
    components: Map<string, Component>

    constructor(...components: Component[]) {
        this.components = new Map()

        components.forEach(component => this.add(component))
    }

    get<C extends Component[]> (...components: { [P in keyof C]: new (...args: any[]) => C[P] }): Record<string, Component> {
      return components.reduce(
            (accumulator, component) => {
                accumulator[component.name.toLocaleLowerCase()] =
                    this.components.get(component.name)!

                return accumulator
            },
            {} as Record<string, Component>
        )
    }

    add(component: Component) {
        this.components.set(component.name, component)
    }
}

const player = new Entity(
                    new Rectangle(100, 100, 100, 100),
                    new Direction(1, 1)
                )

const p_components = player.get(Rectangle, Direction)

console.log(p_components.rectangle)
// Intellisense doens't know p_components properties
// Intellisense infers p_components.rectangle as Components - It should be Rectangle instead

I've also searched the web and the closest to this I've found is this. But I don't know how I can implement it in my code and if it helps.


Solution

  • TypeScript doesn't give string literal types to the name property of class constructors. Instead it's just string, which is true, but not helpful in what you're trying to do. There have been various requests for something like this, such as microsoft/TypeScript#47203 but they have been closed in favor of microsoft/TypeScript#1579 waiting for such a time as JavaScript actually provides a consistent nameof operator, which might never happen.

    Until and unless that changes, if you want to have strongly typed strings to identify classes, you'll have to maintain them yourself, manually. For example, you could give each class instance a name property that you need to implement:

    abstract class Component {
        abstract readonly name: string;
    }
    
    class Direction extends Component {
        readonly name = "Direction"
        // ✂ ⋯ rest of the class impl ⋯ ✂
    }
    
    class Rectangle extends Component {
        readonly name = "Rectangle"
        // ✂ ⋯ rest of the class impl ⋯ ✂
    }
    

    That's a manual step you need to take. But now if you look at, say, the name property of the Rectangle type, you'll see that it is strongly typed:

    type RectangleName = Rectangle['name']
    // type RectangleName = "Rectangle"
    

    Okay and now you can write your get() method, fairly similarly to how you were doing it:

    get<C extends Component[]>(
        ...components: { [P in keyof C]: new (...args: any) => C[P] }
    ): { [T in C[number] as Lowercase<T['name']>]: T } {
        return components.reduce(
            (accumulator, component) => {
                accumulator[component.name.toLowerCase()] =
                    this.components.get(component.name)!
    
                return accumulator
            },
            {} as any
        )
    }
    

    The input type is the same, a mapped array/tuple type over the generic C, which is constrained to an array of Component instance types. So if you call get(Rectangle, Direction), then C would be the tuple type [Rectangle, Direction].

    The output type is the type you care about... in this case it's { [T in C[number] as Lowercase<T['name']>]: T }. This is a key-remapped type over the type C[number], which is the union of element types of C. (If C is an array type, and you index into it with a key of a number type, then you get an element of the array. So the indexed access type C[number] corresponds to the element type of the array. See Typescript interface from array type for more information. )

    For each component type T in C[number], we want the key in the output to be Lowercase<T['name']>. That is, we're indexing into T with name to get the strongly-typed name, and then we're using the LowerCase<T> utility type to convert it to lower case... to correspond to the behavior of component.name.toLowerCase(). Note that I changed it from toLocaleLowerCase() to the locale-independent toLowerCase(). TypeScript has absolutely no idea of what locale the runtime is going to use, and unless you do, then you don't want to make the mistake of assuming that the locale's lowercase rules will turn "Rectangle" into "rectangle". If you have a component named "Index" and the runtime locale happens to be Turkish, it will become "ındex" instead of "index" and you'll have a bad time.

    Again, for each component type T in C[number], the key will be the locale-independent lower-cased version of the name property. And the value will just be T. Hence, { [T in C[number] as Lowercase<T['name']>]: T }.


    Let's test it out:

    const p_components = player.get(Rectangle, Direction);
    // const p_components: {
    //   direction: Direction;
    //   rectangle: Rectangle;
    // }
    

    That looks like what you want.

    PLEASE NOTE that this only helps you with typings, not really with implementation. You can call player.get() with a subclass of Component that was never add()ed to it, and get undefined at runtime when the typings says it will be present. You can fix the implementation to be more generic, or the typings to account for undefined, but I consider that out of scope for the question as asked.

    Playground link to code