TypeScript Liskov替换检查对于重载的行为不一致,

qvsjd97n  于 6个月前  发布在  TypeScript
关注(0)|答案(5)|浏览(63)

Bug报告

🔎 搜索关键词

  • liskov
  • addEventListener
  • overload

🕗 版本与回归信息

  • 在我尝试的每个版本中,这种行为都是正常的,我已经查阅了关于所有问题的常见问题解答。我已经检查了完整的常见问题解答。

⏯ Playground链接

带有相关代码的Playground链接

💻 代码

interface FooBarEventMap {
  click: CustomEvent;
}

class FooBarElement extends HTMLElement {

}

interface FooBarElement extends HTMLElement {
  addEventListener<T extends keyof FooBarEventMap>(
    type: T,
    listener: (this: FooBarElement, ev: FooBarEventMap[T]) => any,
    options?: boolean | AddEventListenerOptions,
  ): void;
}

interface HTMLElementTagNameMap {
    "foo-bar": FooBarElement;
}

let one: NodeListOf<Node> = document.querySelectorAll('foo-bar')!;

one.forEach(el => el.addEventListener('click', null));

let two: NodeListOf<Node> = document.querySelectorAll('div')!;

two.forEach(el => el.addEventListener('click', null));

let three: NodeListOf<FooBarElement> = document.querySelectorAll('foo-bar')!;

three.forEach(el => el.addEventListener('click', null));

let four: NodeListOf<HTMLDivElement> = document.querySelectorAll('div')!;

four.forEach(el => el.addEventListener('click', null));

🙁 实际行为

对于 one 的赋值报告了一个错误。
从技术上讲,这是正确的: FooBarElementHTMLElement 的子类型,而 HTMLElement 又是一个 Node 的子类型。FooBarElement.addEventListener() 不接受 Node.addEventListener() 所接受的所有参数,因此这是一个 LSP 违规。
然而,对于 HTMLDivElement 也是如此。对 two 的赋值被接受,但正如对 four 的赋值所证明的那样,一个 HTMLDivElement 实际上并不接受 null
当我将 addEventListener 的重载复制到:

interface FooBarElement extends HTMLElement {
  addEventListener<T extends keyof FooBarEventMap>(
    type: T,
    listener: (this: FooBarElement, ev: FooBarEventMap[T]) => any,
    options?: boolean | AddEventListenerOptions,
  ): void;
  addEventListener<T extends keyof FooBarEventMap>(
    type: T,
    listener: (this: FooBarElement, ev: FooBarEventMap[T]) => any,
    options?: boolean | AddEventListenerOptions,
  ): void;
}

... 这两个重载完全相同。然后对 one 的赋值不再报告为错误。

🙂 预期行为

我不希望看到这种不一致的行为:

  • 当将 NodeListOf<Node> 分配给 HTMLDivElement 和 FooBarElement 时,应该报告错误,或者两者都不应该报告错误。
  • 添加一个相同的额外重载不应该使错误消失。

其他上下文

实际世界的应用场景是使用具有 strict: true 配置的 tsconfig.json 来消费 WoltLab/d.ts 仓库。
在该仓库中有一个元素具有不兼容的重载:
https://github.com/WoltLab/d.ts/blob/57f923f03e37885885326bbd622d15b554feb9b5/WoltLabSuite/Core/Element/woltlab-core-dialog.d.ts#L40
并且它在 global.d.ts 中的注册位置是:
https://github.com/WoltLab/d.ts/blob/57f923f03e37885885326bbd622d15b554feb9b5/global.d.ts#L122
现在,当尝试编译一个将该仓库作为依赖项的项目时,会报告以下错误:

node_modules/typescript/lib/lib.dom.d.ts:10535:87 - error TS2344: Type 'HTMLElementTagNameMap[K]' does not satisfy the constraint 'Node'.
  Type 'HTMLInputElement | HTMLElement | WoltlabCoreDialogElement | HTMLDialogElement | WoltlabCoreDialogControlElement | ... 66 more ... | HTMLVideoElement' is not assignable to type 'Node'.
    Type 'WoltlabCoreDialogElement' is not assignable to type 'Node'.
      Types of property 'addEventListener' are incompatible.
        Type '<T extends keyof WoltlabCoreDialogEventMap>(type: T, listener: (this: WoltlabCoreDialogElement, ev: WoltlabCoreDialogEventMap[T]) => any, options?: boolean | ... 1 more ... | undefined) => void' is not assignable to type '(type: string, callback: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions | undefined) => void'.
          Types of parameters 'listener' and 'callback' are incompatible.
            Type 'EventListenerOrEventListenerObject | null' is not assignable to type '(this: WoltlabCoreDialogElement, ev: CustomEvent<any> | CustomEvent<ValidateCallback[]>) => any'.
              Type 'null' is not assignable to type '(this: WoltlabCoreDialogElement, ev: CustomEvent<any> | CustomEvent<ValidateCallback[]>) => any'.

10535     querySelectorAll<K extends keyof HTMLElementTagNameMap>(selectors: K): NodeListOf<HTMLElementTagNameMap[K]>;
kupeojn6

kupeojn61#

相关问题:是否可以自动继承父类型(即HTMLElement)的所有重载,以添加更具体的重载,还是我需要使用复制粘贴?

wz8daaqr

wz8daaqr2#

这是一个不需要DOM库的复现示例。

工作假设:这里的核心问题在于(为了避免组合爆炸),当源签名被重载时,我们取签名的泛型约束的擦除形式,这实际上将

addEventListener<T extends keyof Demo2Map>(type: T, listener: (ev: Demo2Map[T]) => any): void;

转换为

addEventListener(type: keyof Demo2Map, listener: (ev: Demo2Map[keyof Demo2Map]) => any): void;

这就是为什么 Demo2Map 的内容对复现至关重要,因为如果它的任何条目仅仅是 MyEvent ,那么就不再有错误了——我们可能在进行逆变操作时进行了可疑的子类型约简,而实际上应该进行超类型约简。

话虽如此,我们四个人花了一个小时研究这个问题,但我们仍然不确定 | MyEventListenerObject 为什么在这里很重要。
两种可能的结果是这个根本无法修复,或者经过四十个小时的调试后,有人可能会产生一个三行修复方案。在我们尝试之前很难说。

0vvn1miw

0vvn1miw3#

@RyanCavanaugh 谢谢你。我在那里找到了什么?
你是否能给我关于后续评论中的问题的一些提示?
是否有可能自动继承父类型(即HTMLElement)的所有重载,以添加一个更具体的重载,还是我需要手动复制粘贴?
我们的addEventListener定义实际上对我来说是无效的(即我们拒绝在自定义元素中调用HTMLElement的有效调用)。如果我有一些语法可以继承HTMLElement["addEventListener"]的所有重载,然后再添加我的单个更具体的重载,这可能会解决这个问题的症状(由于重载),同时使定义更加正确。我想避免将原始定义从HTMLElement复制到每个自定义元素中。

fquxozlt

fquxozlt4#

不,很遗憾,我们目前没有一个明确的方法来表示"所有那些重载加上这个",你可以通过交集来实现一些技巧,例如:

addEventListener: HTMLElement["addEventListener"] & {
  (s: string): void;
}

请注意,这个操作 & 是顺序敏感的(就像重载本身一样)

am46iovg

am46iovg5#

感谢您。参考:以下内容似乎在我们的实际应用场景中起作用。很高兴您提到顺序很重要,新的重载需要先出现,因为它更具体:

export interface WoltlabCoreDialogElement extends HTMLElement {
  addEventListener: {
    <T extends keyof WoltlabCoreDialogEventMap>(
      type: T,
      listener: (this: WoltlabCoreDialogElement, ev: WoltlabCoreDialogEventMap[T]) => any,
      options?: boolean | AddEventListenerOptions,
    ): void
  } & HTMLElement["addEventListener"];
}

相关问题