Typescript – 如何从动态创建的对象中推断正确的子类类型

huangapple go评论176阅读模式
英文:

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

问题

这是对这个问题的后续问题

我想要实现的是:
将多个子类传递给一个函数,是否有一种方法可以返回一个具有传递的子类类型的“模式”对象?这样我就可以保留所有的智能感知功能。

以下是我失败的尝试。

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

我还在网上搜索过,最接近的是这个。但我不知道如何在我的代码中实现它,也不知道它是否有帮助。

英文:

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&lt;string, Component&gt;
constructor(...components: Component[]) {
this.components = new Map()
components.forEach(component =&gt; this.add(component))
}
get&lt;C extends Component[]&gt; (...components: { [P in keyof C]: new (...args: any[]) =&gt; C[P] }): Record&lt;string, Component&gt; {
return components.reduce(
(accumulator, component) =&gt; {
accumulator[component.name.toLocaleLowerCase()] =
this.components.get(component.name)!
return accumulator
},
{} as Record&lt;string, Component&gt;
)
}
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&#39;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.

答案1

得分: 3

TypeScript不会为类构造函数的name属性提供字符串字面类型。相反,它只是string类型,这是正确的,但对你想要做的事情没有帮助。已经有了各种请求,希望有类似的功能,比如microsoft/TypeScript#47203,但它们已经被关闭,支持microsoft/TypeScript#1579,等待JavaScript实际提供一致的nameof运算符,这可能永远不会发生。

在这种情况下,如果你想要有强类型的字符串来标识类,你将不得不自己手动维护它们。例如,你可以给每个类实例添加一个需要实现的name属性:

abstract class Component {
    abstract readonly name: string;
}

class Direction extends Component {
    readonly name = "Direction";
    // 其他类的实现...
}

class Rectangle extends Component {
    readonly name = "Rectangle";
    // 其他类的实现...
}

这是你需要执行的手动步骤。现在,如果你查看Rectangle类型的name属性,你会发现它是强类型的:

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

好的,现在你可以编写你的get()方法,与你之前的方式非常相似:

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

输入类型与之前相同,是对泛型C映射数组/元组类型,它被约束Component实例类型的数组。因此,如果你调用get(Rectangle, Direction),那么C将是元组类型[Rectangle, Direction]

输出类型是你关心的类型...在这种情况下,它是{ [T in C[number] as Lowercase<T['name']>]: T }。这是对类型C[number]键重新映射类型,其中C[number]C的元素类型的联合类型。(如果C是一个数组类型,并且你使用number类型的键索引它,那么你将得到数组的一个元素。因此,索引访问类型C[number]对应于数组的元素类型。有关更多信息,请参见https://stackoverflow.com/q/45607685/2887218。)

对于C[number]中的每个组件类型T,我们希望输出中的键是Lowercase<T['name']> 。也就是说,我们使用nameT进行索引,以获取强类型的名称,然后使用LowerCase<T>实用类型将其转换为小写...以对应于component.name.toLowerCase()的行为。请注意,我将其从toLocaleLowerCase()更改为与语言环境无关的toLowerCase()。TypeScript绝对不知道运行时将使用什么语言环境,除非你知道,否则你不希望犯将运行时语言环境的小写规则将"Rectangle"转换为"rectangle"的错误。如果你有一个名为"Index"的组件,并且运行时语言环境恰好是土耳其语,它将变成"ındex"而不是"index",你会遇到麻烦。

同样,对于C[number]中的每个组件类型T,键将是name属性的与语言环境无关的小写版本。值将是T本身。因此,{ [T in C[number] as Lowercase<T['name']>]: T }

让我们来测试一下:

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

看起来符合你的要求。

请注意,这只对类型有帮助,而不是实际的实现。你可以使用Component的子类调用player.get(),而这些子类从未被add()到其中,运行时会得到undefined,而类型系统却说它会存在。你可以修复实现以更通用,或者调整类型以考虑undefined,但我认为这超出了所提问的问题的范围。

英文:

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 = &quot;Direction&quot;
// ✂ ⋯ rest of the class impl ⋯ ✂
}
class Rectangle extends Component {
readonly name = &quot;Rectangle&quot;
// ✂ ⋯ 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[&#39;name&#39;]
// type RectangleName = &quot;Rectangle&quot;

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

get&lt;C extends Component[]&gt;(
...components: { [P in keyof C]: new (...args: any) =&gt; C[P] }
): { [T in C[number] as Lowercase&lt;T[&#39;name&#39;]&gt;]: T } {
return components.reduce(
(accumulator, component) =&gt; {
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&lt;T[&#39;name&#39;]&gt;]: 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 https://stackoverflow.com/q/45607685/2887218 for more information. )

For each component type T in C[number], we want the key in the output to be Lowercase&lt;T[&#39;name&#39;]&gt;. That is, we're indexing into T with name to get the strongly-typed name, and then we're using the LowerCase&lt;T&gt; 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 &quot;Rectangle&quot; into &quot;rectangle&quot;. If you have a component named &quot;Index&quot; and the runtime locale happens to be Turkish, it will become &quot;ındex&quot; instead of &quot;index&quot; 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&lt;T[&#39;name&#39;]&gt;]: 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

huangapple
  • 本文由 发表于 2023年8月9日 05:17:24
  • 转载请务必保留本文链接:https://go.coder-hub.com/76863251.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定