타입 안전한 CustomEvents: 네이티브 API로 더 나은 메시징
Source: Dev.to

네이티브 EventTarget은 내부 메시징을 위한 숨겨진 보석입니다. 내장되어 있고 빠르며 방해가 되지 않습니다. 하지만 표준 CustomEvent 인터페이스는 TypeScript 입장에서 약간의 “블랙 박스”입니다. 보통 데이터를 접근하려면 매번 e as CustomEvent 로 캐스팅하게 됩니다.
우리는 더 나은 방법을 사용할 수 있습니다. 타깃과 이벤트 생성을 래핑함으로써 런타임 오버헤드 없이 완전히 타입‑안전한 이벤트 버스를 만들 수 있습니다.
문제
CustomEvent를 디스패치하면 데이터가 detail 속성에 숨겨집니다. 기본적으로 addEventListener는 그 detail이 무엇을 포함하는지 알지 못하므로, 리스너를 직접 타입 지정해야 합니다.
해결책: TypedEventTarget
type EventListener = (evt: E) => void;
interface EventListenerObject {
handleEvent(evt: CustomEvent): void;
}
// The type of our listener receives the CustomEvent with our specific data
type TEL = EventListener> | EventListenerObject>;
export class TypedEventTarget> {
private readonly target = new EventTarget();
addEventListener(
type: K & string,
listener: TEL,
options?: boolean | AddEventListenerOptions,
) {
// We cast to EventListenerOrEventListenerObject because the browser expects the base Event type
this.target.addEventListener(type, listener as EventListenerOrEventListenerObject, options);
}
removeEventListener(
type: K & string,
listener: TEL,
options?: boolean | EventListenerOptions,
) {
this.target.removeEventListener(type, listener as EventListenerOrEventListenerObject, options);
}
// A helper to ensure we always dispatch a properly formatted CustomEvent
dispatchEvent(type: K, ...args: M[K] extends void ? [detail?: undefined] : [detail: M[K]]) {
const [detail] = args;
return this.target.dispatchEvent(new CustomEvent(String(type), { detail }));
}
}
물론 이것은 기본 클래스일 뿐이며, 자체 데이터를 사용하려면 이를 확장해야 합니다.
실제 사용 사례
온라인 쇼핑 카트를 추적하고 싶다고 가정해 보겠습니다. 사이트 어디에서든 카트를 변경할 수 있어야 하지만 UI나 총 가격이 동기화되지 않기를 원합니다.
import { TypedEventTarget } from './TypedEventTarget';
export interface CartItem {
id: string;
name: string;
price: number;
}
// This map defines exactly what data each event emits
type ShoppingCartEvents = {
'item-added': CartItem;
'item-removed': { id: string };
'cart-cleared': void;
};
export default class ShoppingCart extends TypedEventTarget {
private _items: CartItem[] = [];
addItem(item: CartItem) {
this._items.push(item);
this.dispatchEvent('item-added', item);
}
removeItem(id: CartItem['id']) {
this._items = this._items.filter(item => item.id !== id);
this.dispatchEvent('item-removed', { id });
}
clear() {
this._items = [];
this.dispatchEvent('cart-cleared');
}
get items() {
return this._items;
}
get total() {
return this.items.reduce((sum, item) => sum + item.price, 0);
}
}
이제 데이터를 저장할 클래스를 만들었으니, 자동으로 이벤트를 디스패치하는 메서드를 추가한 것을 볼 수 있습니다. 이러한 이벤트가 엄격하게 타입이 지정되어 있기 때문에, 이벤트를 청취할 때 완전한 자동 완성과 안전성을 얻을 수 있습니다:
shoppingCartSingleton.addEventListener('item-added', event => {
const item = event.detail; // TypeScript knows this is a CartItem
console.log(item);
});
데모
위 클래스를 사용한 간단한 데모입니다.
(데모 코드는 간결함을 위해 생략되었습니다.)
Signing‑off
저는 이 기법으로 가능한 것의 표면만 살짝 건드렸을 뿐입니다. 우리는 다양한 주류 프레임워크가 각각 다른 방식을 가지고 있는 세상에 살고 있습니다. 저는 이제 일반화된 싱글톤이 돌아오는 순간에 서 있다고 생각합니다: 선택한 프레임워크에 연결될 수 있는 하나의 네이티브 JS/TS 클래스. 하지만 그것은 또 다른 날, 어쩌면 또 다른 포스트에서 다룰 의견입니다.
읽어 주셔서 감사합니다! 연결하고 싶으시면 제 BlueSky와 LinkedIn 프로필을 확인해 주세요. 인사해 주세요 😊