かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

TypeScript Event.target, Event.currentTarget の型がむずい!

TypeScript で addEventListener のコールバック関数で Event.target, Event.currentTarget を使おうとしたら結構めんどかったのでメモ

Event.target と Event.currentTarget の違い

これはそもそも JavaScript の違いですが、Event.target はイベントが発生した要素を返すので必ずしもイベントリスナーを付けた対象になるとは限りません。

The read-only target property of the Event interface is a reference to the object onto which the event was dispatched. It is different from Event.currentTarget when the event handler is called during the bubbling or capturing phase of the event.
cf. Event.target - Web APIs | MDN

// HTML
<button><span>Button</span></button>

buttonElement.addEventListener('click', (evt) => {
  evt.target; // => span だったりと button とは限らない
});

一方で Event.currentTarget はイベントリスナーを付けた要素が返されます。
cf. Event.currentTarget - Web API | MDN

// HTML
<button><span>Button</span></button>

buttonElement.addEventListener('click', (evt) => {
  evt.currentTarget; // => button 
});

TypeScript での Event.target

Event.target の対象は変化するので

const input = <HTMLInputElement>document.getElementById('input');

input.addEventListener('change', (evt) => {
  evt.target.value;
  // => Property 'value' does not exist on type 'EventTarget'. ts(2339)
});

evt.targetEventTarget 型で必ずしも HTMLInputElement が来るわけではないから evt.target.value が取得できるかどうか判らないので TypeError になります。
なので、evt.target instanceof HTMLInputElement で Type Guard してあげれば OK

input.addEventListener('change', (evt) => {
  if (!(evt.target instanceof HTMLInputElement)) {
    return;
  }
  evt.target.value; // OK
});

TypeScript での Event.currentTarget

Event.target は変化するので、React のイベントリスナーでも target を使っていると currentTarget を使うように促されます。
Event.currentTarget はイベントリスナーを付けた要素なので addEventListener でも…

const input = <HTMLInputElement>document.getElementById('input');

input.addEventListener('change', (evt) => {
  evt.currentTarget.value;
  // => Property 'value' does not exist on type 'EventTarget'. ts(2339)
});

なんでや!
addEventListener のコールバック内の evt.currentTargetevt.target と同じ EventTarget 型と判断されてしまい、evt.currentTarget.value が存在するとは限らないとしてエラーになってしまいました。

XMLHttpRequest object also can be currentTarget but it is not Element/HTMLElement. Maybe that's the reason behind this. Would generic type improve this?
cf. Request to change currentTarget in Event interface for lib.d.ts · Issue #299 · microsoft/TypeScript · GitHub

MDN にも

event.currentTarget The EventTarget whose EventListeners are currently being processed. As the event capturing and bubbling occurs, this value changes.
cf. Comparison of Event Targets - Web APIs | MDN

と書かれていました。
どうやら XMLHttpRequest なども currentTarget になることができるので、必ずしも Element, HTMLElement とは言い切れないとなっているようです。GitHub の issue も 2014 年に建ててられ Open になったままなので JavaScript 起因の根が深い問題っぽい…

1. instanceof で Type Guard する

Event.target と同様に Type Guard を使用することで解決ができます。

input.addEventListener('change', (evt) => {
  if (!(evt.currentTarget instanceof HTMLInputElement)) {
    return;
  }
  evt.currentTarget.value; // OK
});

とはいえイベントの度に if 分実行されるの微妙…

2. as でキャストしてしまう

コンパイラより儂の方が正しいんじゃい方法。

input.addEventListener('change', (evt) => {
  (evt.currentTarget as HTMLInputElement).value; // OK
});

キャストだからエラーにはならないよね、、、

3. Event の型を拡張した Event.currentTarget を Generic で渡せる型を作ってしまう

addEventListener のコールバックに渡される引数は Event 型なので、Event.currentTarget の型を指定できる型で指定すればいい感じになりそうです。

Event.currentTarget の型を指定できる Event 型の拡張型を作成

interface HTMLElementEvent<T extends EventTarget> extends Event {
  currentTarget: T;
}

これを使えばいい感じに…

interface HTMLElementEvent<T extends EventTarget> extends Event {
  currentTarget: T;
}

const input = <HTMLInputElement>document.getElementById('input');
input.addEventListener('change', (evt: HTMLElementEvent<HTMLInputElement>) => {
  evt.currentTarget.value;
});

の筈だったのですが、(evt: HTMLElementEvent<HTMLInputElement>) の部分でエラーが発生してしまいます。

No overload matches this call.
  Overload 1 of 2, '(type: "change", listener: (this: HTMLInputElement, ev: Event) => any, options?: boolean | AddEventListenerOptions): void', gave the following error.
    Argument of type '(evt: HTMLElementEvent<HTMLInputElement>) => void' is not assignable to parameter of type '(this: HTMLInputElement, ev: Event) => any'.
      Types of parameters 'evt' and 'ev' are incompatible.
        Type 'Event' is not assignable to type 'HTMLElementEvent<HTMLInputElement>'.
          Types of property 'currentTarget' are incompatible.
            Type 'EventTarget' is missing the following properties from type 'HTMLInputElement': accept, align, alt, autocomplete, and 326 more.
  Overload 2 of 2, '(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void', gave the following error.
    Argument of type '(evt: HTMLElementEvent<HTMLInputElement>) => void' is not assignable to parameter of type 'EventListenerOrEventListenerObject'.
      Type '(evt: HTMLElementEvent<HTMLInputElement>) => void' is not assignable to type 'EventListener'.
        Types of parameters 'evt' and 'evt' are incompatible.
          Type 'Event' is not assignable to type 'HTMLElementEvent<HTMLInputElement>'.ts(2769)

なんでや!!!
エラーが長ぇ…

イベントリスナーのコールバック関数ををオブジェクト指定にすればエラーにならない

イベントリスナーには、コールバック関数を指定することもできますが、 EventListener を実装したオブジェクトを指定することもでき、その場合は handleEvent() (en-US) メソッドがコールバック関数として機能します。
コールバック関数自体は、 handleEvent() メソッドと同じ引数と返値を持ちます。つまり、コールバック関数は発生したイベントを説明する Event に基づいたオブジェクトを唯一の引数として受け付け、何も返しません。
cf. EventTarget.addEventListener() - Web API | MDN

先のスクリプトを下記のように変更すれば OK

interface HTMLElementEvent<T extends EventTarget> extends Event {
  currentTarget: T;
}

const input = <HTMLInputElement>document.getElementById('input');
input.addEventListener('change', {handleEvent: (evt: HTMLElementEvent<HTMLInputElement>) => {
  evt.currentTarget.value;
}});

これで Event.currentTarget の型問題が解決しました!
ただ見慣れない書き方なので、素直に Type Guard した方が良いのかも知れない… TypeScript なんもわからん

所管

Event.currentTarget でここまで苦労するとは思わなかったよ。パトラッシュ…
と TypeScript の <T> これ Generic なのか Generics なのか、読み方も ジェネリック なのか ジェネリックス なのか ジェネリクス なのか分からん。(英語見た目と読み方が違うやつ多くてムズい…)


[参考]

銀河

銀河

Amazon
さよポニの新アルバム楽しみ〜