나만의 컬러 피커 커스텀 엘리먼트를 만들었고, updateColor 발생 시 커스텀 이벤트를 디스패치하는 상황이었다.
class MyColorPicker extends HTMLElement {
constructor() {
super()
}
updateColor(color: string) {
this.dispatchEvent(new CustomEvent("update-color", {
detail: { color }
}));
}
}
customElements.define("my-color-picker", MyColorPicker);
이를 등록한 이벤트 리스너에서 발생한 커스텀 이벤트 정보를 가져오고 싶다.
function main() {
const cp = document.createElement("my-color-picker") as MyColorPicker
cp.addEventListener("update-color", (e) => {
console.log("change color: ", e.detail.color)
})
cp.updateColor("red")
}
Property 'detail' does not exist on type 'Event'. 에러 발생.
TypeScript는 기본적으로 addEventListener에 들어오는 이벤트 객체를 가장 뿌리가 되는 Event 타입으로 간주한다. Event는 모든 이벤트의 조상으로 target, type 같은 기본 속성만 있을 뿐, detail이란 속성은 없다.
cp.addEventListener("update-color", (e: CustomEvent) => {
console.log("change color: ", e.detail.color)
})
그럼 저 e의 타입은 커스텀 이벤트가 되어야 맞고, 코드를 수정하면 detail에 그인 에러는 addEventListener에 그인다.
No overload matches this call. Overload 1 of 2, '(type: keyof HTMLElementEventMap, listener: (this: HTMLElement, ev: Event | UIEvent | AnimationEvent | PointerEvent | MouseEvent | ... 14 more ... | WheelEvent) => any, options?: boolean | ... 1 more ... | undefined): void', gave the following error. Argument of type '"update-color"' is not assignable to parameter of type 'keyof HTMLElementEventMap'. Overload 2 of 2, '(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions | undefined): void', gave the following error. Argument of type '(e: CustomEvent) => void' is not assignable to parameter of type 'EventListenerOrEventListenerObject'. Type '(e: CustomEvent) => void' is not assignable to type 'EventListener'. Types of parameters 'e' and 'evt' are incompatible. Type 'Event' is missing the following properties from type 'CustomEvent<any>': detail, initCustomEvent
addEventListener는 첫 번째 인자인 type이 무엇인지에 따라 두 번째 인자인 listener 타입을 결정하는데, 'update-color'란 타입이 없어서 발생한다. 그러니까 기본 등록된 click, scroll를 기대하고 있는데 엉뚱한 것이 들어왔고 또 addEventListener의 두 번째 인자는 (e: Event) => void를 기대하는데 Event 대신 CustomEvent를 써버렸다. 이를 해결하기 위해서는 addEventListener 메서드를 오버로딩 해줘야 한다.
class BaseComponent<T extends Record<string, any>> extends HTMLElement {}
다른 커스텀 컴포넌트들도 다 같이 적용되어야 할 테니 부모 클래스를 만들어 줬고, 저기 제네릭에는 이벤트를 정의해둔 인터페이스를 받으면 될 것 같다.
interface MyColorPickerCustomEvents {
"update-color": { color: string }
}
class MyColorPicker extends BaseComponent<MyColorPickerCustomEvents> {}
커스텀 이벤트 정보를 담은 인터페이스를 만들고, HTMLElement 대신 좀 전에 만든 부모 클래스를 확장하면서 만든 인터페이스를 등록한다. 그리고 첫 번째 오버로드, 이것의 목적은 우리가 선언한 커스텀 이벤트 타입을 등록해서 타입스크립트에게 알려준다.
override addEventListener<
K extends keyof (HTMLElementEventMap & { [P in keyof T]: CustomEvent<T[P]> })
>(
type: K,
listener: (
this: this,
ev: (HTMLElementEventMap & { [P in keyof T]: CustomEvent<T[P]> })[K]
) => any,
options?: boolean | AddEventListenerOptions
): void;
[P in keyof T] 부터 보면, T는 우리가 보낸 인터페이스다.
mapped types으로 그 인터페이스의 키를 하나씩 꺼내 P라는 변수를 사용해 키(update-color), CustomEvent<{ color: string }>를 값으로 묶어 새로운 객체를 만든다.
쉽게 T, { "update-color": { color: string } }를 { "update-color": CustomEvent<{ color: string }> }로 바꾼 것.
그렇게 우리가 등록한 인터페이스의 모든 키에 대해 CustomEvent를 기존 이벤트 HTMLElementEventMap과 합치고 거기서 key만 뽑아내 K를 만들어 addEventListener의 첫 인자로 보내준다.
참고로 HTMLElementEventMap는
interface GlobalEventHandlersEventMap {
...
"click": PointerEvent;
...
}
이런 기존 이벤트를 모은 인터페이스다.
두 번째 인자 리스너의 첫 번재 인자를 this로 지정하여 this가 단순히 HTMLElement가 아닌 현재 클래스 MyColorPicker임을 보장해야 리스너 안에서 해당 클래스의 멤버에 안전하게 접근할 수 있다.
두 번째 인자 리스너의 두 번째 인자 ev는 기존 이벤트와 등록한 커스텀 이벤트를 합친 객체에 키 즉 K를 넣어서 이벤트 객체를 가져온다.
cp.addEventListener("update-color", (e: CustomEvent) => {
console.log("change color: ", e.detail.color)
})
이렇게 update-color를 넣으면 그에 해당하는 값을 가져오게 되고 그 값은? 위에서 봤듯이 CustomEvent<{ color: string }>가 된다.
addEventListener의 세 번째 인자는 addEventListener 본래 코드 그대로 넣어준다.
두 번째 오버로드는 등록하지 않은 이벤트도 사용할 수 있도록 string으로 선언하는 건데 빼도 상관 없을 것 같다.
override addEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions
): void;
그리고 세 번째는 타입스크립트 규칙에 의해 앞서 선언한 오버로드 모두를 수용하는 실제 구현을 써주는데 그냥 기존 addEventListener 호출을 래핑해주면 된다.
override addEventListener(
type: string,
listener: any,
options?: boolean | AddEventListenerOptions
): void {
super.addEventListener(type, listener, options);
}
이러면 이제 addEventListener를 사용하는 순간, 순서대로 먼저 커스텀 이벤트를 체크하고, 해당되지 않으면 다음 유연한 string으로 처리된다. removeEventListener도 이와 같이 만들어주면 완성.
마지막으로 HTMLElementTagNameMap에 내 커스텀 엘리먼트를 추가한다. 추가하지 않으면 createElement로 커스텀 엘리먼트를 만들었을 때 타입을 HTMLElement로 추정하게 되는데, 거기엔 지금 만든 커스텀 오버로딩이 없다.
declare global {
interface HTMLElementTagNameMap {
"my-color-picker": MyColorPicker;
}
}
이제 createElement로 생성 후 as MyColorPicker 같은 타입 단언을 하지 않아도 된다!
또 e의 정확한 타입은 CustomEvent<MyColorPickerCustomEvents ['update-color]>기 때문에 CustomEvent라고 명시하지 말고 지금 설계한 타입 추론 시스템이 추론하게 내버려 두어야 e.detail까지 쳤을 때 color가 자동완성 된다.
function main() {
const cp = document.createElement("my-color-picker")
cp.addEventListener("update-color", (e) => {
console.log("change color: ", e.detail.color)
})
cp.updateColor("red")
}