Back

TypeScriptにおける型安全なイベントエミッター

TypeScriptにおける型安全なイベントエミッター

こんな経験はありませんか:イベント名のタイポ、ペイロードの不一致、そして突然リスナーが静かに発火しなくなる。バグの追跡に30分かかり、修正はたった1文字。TypeScriptの型付きイベントエミッターは、エラーを実行時からコンパイル時に移すことで、この種の問題を完全に排除します。

この記事では、TypeScriptのイベントマップパターンの仕組み、独自の抽象化を構築するタイミングと既存のものを使用するタイミング、そして知っておくべき実用的なパターンをいくつか紹介します。

重要なポイント

  • イベントマップインターフェースは単一の情報源として機能し、各イベント名をコンパイル時に正確なペイロード型と結びつけます。
  • K extends keyof TEventsを使用した軽量なジェネリックエミッタークラスは、コードが実行される前にタイポや型の不一致を検出します。
  • 最新の@types/nodeは既に型付きイベントマップをサポートしているため、カスタムクラスが常に必要というわけではありません。
  • 長時間稼働するサービスやUIコンポーネントでメモリリークを防ぐため、リスナー登録には必ず明示的なクリーンアップを組み合わせてください。

型安全なEventEmitterが重要な理由

型なしのアプローチは次のようになります:

import { EventEmitter } from 'events'

const emitter = new EventEmitter()

emitter.on('userLoggedIn', (user) => {
  console.log(user.name) // userは`any`型
})

emitter.emit('userLoggedin', { name: 'Alice' }) // タイポ — エラーなし

この型なしの使用法では、TypeScriptはuserLoggedInuserLoggedinの大文字小文字の違いを検出しません。リスナーは発火せず、何も理由を教えてくれません。

TypeScriptのイベントマップパターン

解決策はシンプルなインターフェース、つまりイベントマップから始まります。各キーはイベント名で、各値はそのペイロード型です:

interface UserEvents {
  userLoggedIn: { userId: string, timestamp: Date }
  userLoggedOut: { userId: string }
  profileUpdated: { userId: string, changes: Record<string, unknown> }
}

このインターフェースは、エミッターができるすべてのことに対する単一の情報源となります。

強く型付けされたカスタムエミッターの構築

以下は、最小限でありながら完全な型付きエミッタークラスです:

type EventCallback<T> = (payload: T) => void

class TypedEventEmitter<TEvents extends Record<string, unknown>> {
  private listeners: {
    [K in keyof TEvents]?: EventCallback<TEvents[K]>[]
  } = {}

  on<K extends keyof TEvents>(event: K, callback: EventCallback<TEvents[K]>): void {
    (this.listeners[event] ??= []).push(callback)
  }

  off<K extends keyof TEvents>(event: K, callback: EventCallback<TEvents[K]>): void {
    const cbs = this.listeners[event]
    if (cbs) this.listeners[event] = cbs.filter(cb => cb !== callback)
  }

  emit<K extends keyof TEvents>(event: K, payload: TEvents[K]): void {
    this.listeners[event]?.forEach(cb => cb(payload))
  }

  once<K extends keyof TEvents>(event: K, callback: EventCallback<TEvents[K]>): void {
    const wrapper: EventCallback<TEvents[K]> = (payload) => {
      this.off(event, wrapper)
      callback(payload)
    }
    this.on(event, wrapper)
  }
}

重要なメカニズムはK extends keyof TEventsです。emit('userLoggedIn', ...)を呼び出すと、TypeScriptはKを正確に'userLoggedIn'に絞り込み、ペイロードが{ userId: string, timestamp: Date }と一致することを確認します。タイポや誤った型はコンパイルエラーになります。

const emitter = new TypedEventEmitter<UserEvents>()

emitter.on('userLoggedIn', ({ userId, timestamp }) => {
  console.log(userId, timestamp) // 完全に型付けされている
})

emitter.emit('userLoggedin', { userId: '1', timestamp: new Date() }) // ❌ コンパイルエラー
emitter.emit('userLoggedIn', { userId: '1' }) // ❌ timestampが不足

Node.js EventEmitterを型安全に使用する

最新の@types/nodeEventEmitterをジェネリッククラスとして公開しているため、ラッパーを書かずに直接型付きイベントを取得できることがよくあります。

import { EventEmitter } from 'events'

interface AppEvents {
  'user:login': [userId: string, email: string]
  'data:sync': [recordCount: number]
}

const emitter = new EventEmitter<AppEvents>()

emitter.on('user:login', (userId, email) => {
  console.log(userId, email)
})

emitter.emit('data:sync', 42)

重要な注意点が1つあります:Nodeのイベントシステムはデフォルトで同期的です。リスナーは登録順に実行され、emitが戻る前に完了します。また、'error'イベントには特別な実行時動作があります:リスナーがアタッチされていない状態で発行されると、Nodeは未捕捉の例外をスローします。events.once()のようなヘルパーユーティリティは、イベントマップの型を完全には保持しない場合があるため、そのような場合には型安全性が低下する可能性があります。

代替手段とライブラリ

ブラウザコンテキストでは、EventTargetCustomEventがネイティブオプションですが、TypeScriptでカスタムEventTargetサブクラスを型付けするのは扱いにくいです。ジェネリック制約がCustomEvent<T>とうまく整合しません。

mitteventemitter3のようなライブラリは、すぐに使える型付きエミッターを提供しており、独自実装ではなく実戦テスト済みの実装が必要な場合は検討する価値があります。これらはオプションであり、必須ではありません。パターン自体は直接所有できるほど十分に単純です。

実用的なクリーンアップ:メモリリークの回避

不要になったリスナーは必ず削除してください。忘れられたリスナーは、イベント駆動型コードにおけるメモリリークの最も一般的な原因の1つです:

const handler = ({ userId }: { userId: string }) => console.log(userId)

emitter.on('userLoggedOut', handler)

// 後でクリーンアップ時に:
emitter.off('userLoggedOut', handler)

Reactでは、これをuseEffectのクリーンアップと組み合わせます。長時間稼働するサービスでは、どのコンポーネントがリスナーの破棄を担当するかを文書化してください。

まとめ

TypeScriptのイベントマップパターンは、事前に1つのインターフェース定義のコストがかかります。その見返りとして得られるのは、コードベース全体でのイベント名とペイロード型のコンパイル時強制です。静かな不一致もなく、any型のコールバックもなく、すべてのリスナーで完全なIntelliSenseが利用できます。イベントがモジュール境界を越えるシステムでは、このトレードオフはすぐに行う価値があります。

よくある質問

はい。型付きエミッターはNodeのEventEmitter APIを拡張または使用しているため、EventEmitterインスタンスを受け入れるコードとの互換性が保たれます。型制約はコンパイル時にのみ強制され、実行時のオーバーヘッドは発生しないため、下流のライブラリは期待通りに動作し続けます。

イベントマップでイベントの値をvoidとして定義します。例えば、statusCheck: voidのようにします。次に、型がvoidの場合にペイロードパラメータをオプションにするようにemitシグネチャを調整します。これにより、呼び出し側がクリーンに保たれ、引数を渡さずにemit('statusCheck')を呼び出すことができます。

TypeScriptは、型が互換性のない場所で競合をフラグ付けします。最も安全なアプローチは、共通モジュールで単一の共有イベントマップインターフェースを定義し、すべての場所でそれをインポートすることです。これにより、すべてのプロデューサーとコンシューマーが1つの情報源に対して整合性を保ちます。

マイクロフロントエンド間のページ内での軽量なメッセージングには適しています。ただし、独立したチームの数が増えるにつれて、共有スキーマパッケージまたはコード生成ステップとパターンを組み合わせることを検討してください。これにより、個別にデプロイされるアプリケーション間でイベントマップが同期された状態を保つことができます。

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Check our GitHub repo and join the thousands of developers in our community.

OpenReplay