英文:
TypeScript type error when using generic callbacks for different event types
问题
我正在尝试创建一种通用事件处理程序,我想能够指定事件键,比如 "pointermove" 并让 TypeScript 推断事件类型在这种情况下为 PointerEvent,但是当使用多个事件时会出现错误。
这里是一个最小可复现的示例链接。
export type ContainedEvent<K extends keyof HTMLElementEventMap> = {
eventName: K;
callback: ContainedEventCallback<K>;
};
export type ContainedEventCallback<K extends keyof HTMLElementEventMap> = (
event: HTMLElementEventMap[K],
) => void;
export default function useContainedMultiplePhaseEvent<
K extends keyof HTMLElementEventMap = keyof HTMLElementEventMap
>(
el: HTMLElement,
events: ContainedEvent<K>[],
) {
for (const e of events) {
el.addEventListener(e.eventName, (ev) => e.callback(ev));
}
}
const div = document.createElement("div");
const doA: ContainedEventCallback<"pointerdown"> = (
e,
) => {
console.log("A");
};
const doB: ContainedEventCallback<"pointermove"> = (
e,
) => {
console.log("B");
};
useContainedMultiplePhaseEvent(div, [
{
eventName: "pointerdown",
callback: doA,
},
{
eventName: "pointermove",
callback: doB,
},
]);
英文:
I'm trying to make some kind of a generic event handler, I want to be able to specify the event key like "pointermove" and typescript to infer the event type in this case PointerEvent, but I get an error when using more than one event.
here's a minimal repoducible example
export type ContainedEvent< K extends keyof HTMLElementEventMap> = {
eventName: K;
callback: ContainedEventCallback< K>;
};
export type ContainedEventCallback< K extends keyof HTMLElementEventMap> = (
event: HTMLElementEventMap[K],
) => void;
export default function useContainedMultiplePhaseEvent<
K extends keyof HTMLElementEventMap = keyof HTMLElementEventMap
>(
el: HTMLElement ,
events: ContainedEvent<K>[],
) {
for (const e of events) {
el.addEventListener(e.eventName, (ev) => e.callback(ev));
}
}
const div = document.createElement("div");
const doA: ContainedEventCallback<"pointerdown"> = (
e,
) => {
console.log("A")
};
const doB: ContainedEventCallback<"pointermove"> = (
e,
) => {
console.log("B")
};
useContainedMultiplePhaseEvent(div,
[
{
eventName: "pointerdown",
callback: doA,
},
{
eventName: "pointermove",
callback: doB,
}
]
);
答案1
得分: 1
以下是翻译好的部分:
我认为这里的主要问题是,当TypeScript从数组文字中推断出泛型 元素 类型时,它只会查看数组的第一个元素。这通常是人们想要的,因为它允许像function foo<T>(...args: T[]) {}
这样的东西接受foo("a", "b", "c")
和foo(1, 2, 3)
,但拒绝foo("a", 2, "c")
。也就是说,当从类型为T[]
的值中推断T
时,编译器只会推断同质数组。但在您的情况下,您希望允许异质数组。
在这种情况下的常规方法是更改泛型类型参数,使其引用整个数组,而不仅仅是数组的元素类型。在您的情况下,这意味着不仅要使K
引用ContainedEvent
的类型参数,还要使其引用ContainedEvent
的类型参数的元组类型。也就是说,K
可能类似于["pointerdown", "pointermove"]
,而events
的类型将是[ContainedEvent<"pointerdown">, ContainedEvent<"pointermove">]
。
像这样:
function useContainedMultiplePhaseEvent<K extends readonly (keyof HTMLElementEventMap)[]>(
el: HTMLElement, events: [...{ [I in keyof K]: ContainedEvent<K[I]> }],
) {
for (const e of events) {
el.addEventListener(e.eventName, (ev) => e.callback(ev));
}
}
因此,events
的类型是一个映射元组类型,其中K
的每个元素在数字索引I
处,类型为K[I]
,被包装在ContainedEvent
中以获得ContainedEvent<K[I]>
。噢,events
的类型还包装在可变元组类型 [...⋯]
中,以向编译器提示我们希望events
的类型被推断为元组而不是无序数组(此行为在ms/TS#39094中描述)。
让我们试一试:
useContainedMultiplePhaseEvent(div, [
{ eventName: "pointerdown", callback: doA, },
{ eventName: "pointermove", callback: doB, }
]); // 正常
// useContainedMultiplePhaseEvent<["pointerdown", "pointermove"]>
看起来不错!
这回答了您提出的问题。
还有其他可能的方法,但我不想偏离您的原始代码太远。由于ContainedEvent<K>
中的K
实际上只能是固定的联合类型keyof HTMLElementEventMap
之一,您实际上可以将ContainedEvent
本身变成一个联合类型,可能通过将定义更改为描述分布式对象类型(在ms/TS#47109中描述)。然后,您的useContainedMultiplePhaseEvent
函数将不需要是泛型的,因为events
的每个元素将只是联合类型ContainedEvent
。它看起来像这样:
type ContainedEvent<K extends keyof HTMLElementEventMap = keyof HTMLElementEventMap> =
{ [P in K]: {
eventName: P; callback: ContainedEventCallback<P>;
} }[K];
function useContainedMultiplePhaseEvent(el: HTMLElement, events: ContainedEvent[]) {
events.forEach((e: ContainedEvent) =>
el.addEventListener(e.eventName, (ev) => e.callback(ev)));
}
这也能正常工作。您可以查看ms/TS#47109以了解分布式对象类型的工作原理;否则,我不会在这里偏离主题进行详细说明。
英文:
I think the primary issue here is that when TypeScript infers a generic element type from an array literal, it only consults the first element of the array. This is often what people want, since since it lets something like function foo<T>(...args: T[]) {}
accept foo("a", "b", "c")
and foo(1, 2, 3)
, but reject foo("a", 2, "c")
. That is, when inferring T
from a value of type T[]
, the compiler will only infer homogeneous arrays. But in your case you want to allow a hetereogenous array.
The normal approach in such cases is to change the generic type parameter so that it refers to the entire array and not just the element type of the array. In your case that would mean instead of having K
refer to the type argument for ContainedEvent
, you could make it refer to the tuple of type arguments for a tuple of ContainedEvent
s. Meaning K
would be something like ["pointerdown", "pointermove"]
and events
would be of type [ContainedEvent<"pointerdown">, ContainedEvent<"pointermove">]
.
Like this:
function useContainedMultiplePhaseEvent<K extends readonly (keyof HTMLElementEventMap)[]>(
el: HTMLElement, events: [...{ [I in keyof K]: ContainedEvent<K[I]> }],
) {
for (const e of events) {
el.addEventListener(e.eventName, (ev) => e.callback(ev));
}
}
so the type of events
is a mapped tuple type where each element of K
at numeric-like index I
, of type K[I]
, is wrapped with ContainedEvent
to get ContainedEvent<K[I]>
. Oh and the type of events
is wrapped in a variadic tuple type [...⋯]
to give the compiler a hint that we want events
's type to be inferred as a tuple instead of an unordered array (this behavior is described in ms/TS#39094).
Let's try it out:
useContainedMultiplePhaseEvent(div, [
{ eventName: "pointerdown", callback: doA, },
{ eventName: "pointermove", callback: doB, }
]); // okay
// useContainedMultiplePhaseEvent<["pointerdown", "pointermove"]>
Looks good!
That answers the question as asked.
There are other possible approaches but I didn't want to stray too far from your original code. Since K
in ContainedEvent<K>
can only really be one of a fixed union keyof HTMLElementEventMap
, you could actually make ContainedEvent
into a union itself, possibly by changing the definition to a distributive object type as described in ms/TS#47109. Then your useContainedMultiplePhaseEvent
function wouldn't need to be generic, since each element of events
would just be of the union type ContainedEvent
. That looks like this:
type ContainedEvent<K extends keyof HTMLElementEventMap = keyof HTMLElementEventMap> =
{ [P in K]: {
eventName: P; callback: ContainedEventCallback<P>;
} }[K];
function useContainedMultiplePhaseEvent(el: HTMLElement, events: ContainedEvent[]) {
events.forEach(<K extends keyof HTMLElementEventMap>(e: ContainedEvent<K>) =>
el.addEventListener(e.eventName, (ev) => e.callback(ev)));
}
which also works as desired. You can look at ms/TS#47109 to see how a distributive object type works; otherwise I won't digress here with a detailed explanation.
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论