Back

Типобезопасные Event Emitters в TypeScript

Типобезопасные Event Emitters в TypeScript

Вы наверняка сталкивались с этим: опечатка в имени события, несоответствующая полезная нагрузка — и вдруг слушатель молча перестаёт срабатывать. На поиск бага уходит 30 минут, а исправление — один символ. Типизированные event emitters в TypeScript полностью устраняют эту категорию проблем, перенося ошибку из runtime в compile time.

В этой статье мы покажем, как работает паттерн event map в TypeScript, когда стоит создавать собственную абстракцию, а когда использовать готовые решения, а также рассмотрим несколько практических паттернов, которые стоит знать.

Ключевые выводы

  • Интерфейс event map выступает единственным источником истины, связывая каждое имя события с точным типом его полезной нагрузки на этапе компиляции.
  • Легковесный generic-класс emitter, использующий K extends keyof TEvents, отлавливает опечатки и несоответствия структуры данных ещё до запуска кода.
  • Современный @types/node уже поддерживает типизированные event maps, поэтому кастомный класс не всегда необходим.
  • Всегда сопровождайте регистрацию слушателей явной очисткой, чтобы предотвратить утечки памяти в долгоживущих сервисах и UI-компонентах.

Почему важны типобезопасные EventEmitters

Нетипизированный подход выглядит так:

import { EventEmitter } from 'events'

const emitter = new EventEmitter()

emitter.on('userLoggedIn', (user) => {
  console.log(user.name) // user имеет тип `any`
})

emitter.emit('userLoggedin', { name: 'Alice' }) // опечатка — ошибки нет

При таком нетипизированном использовании TypeScript не поймает разницу в регистре между userLoggedIn и userLoggedin. Слушатель никогда не сработает, и ничто не подскажет вам причину.

Паттерн Event Map в TypeScript

Решение начинается с простого интерфейса — event map, — где каждый ключ — это имя события, а каждое значение — тип его полезной нагрузки:

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

Этот интерфейс становится единственным источником истины для всего, что может делать ваш emitter.

Создание строго типизированного кастомного Emitter

Вот минимальный, но полноценный класс типизированного emitter:

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/node предоставляет EventEmitter как generic-класс, поэтому часто можно получить типизированные события напрямую, без написания обёртки.

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)

Одна важная оговорка: система событий Node синхронна по умолчанию — слушатели выполняются в порядке регистрации до того, как emit вернёт управление. Кроме того, событие 'error' имеет особое поведение в runtime: если оно генерируется без подключённого слушателя, Node выбрасывает необработанное исключение. Вспомогательные утилиты вроде events.once() могут не полностью сохранять типы вашей event map, поэтому типобезопасность в таких случаях может ослабнуть.

Альтернативы и библиотеки

В браузерном контексте нативным вариантом является EventTarget с CustomEvent, но типизация кастомных подклассов EventTarget в TypeScript неудобна — generic-ограничения не согласуются чисто с CustomEvent<T>.

Библиотеки вроде mitt и eventemitter3 предлагают типизированные emitters из коробки и заслуживают внимания, если вы хотите использовать проверенную реализацию вместо создания собственной. Они опциональны, не обязательны — сам паттерн достаточно прост, чтобы реализовать его самостоятельно.

Практическая очистка: избегаем утечек памяти

Всегда удаляйте слушателей, когда они больше не нужны. Забытые слушатели — один из самых распространённых источников утечек памяти в event-driven коде:

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

emitter.on('userLoggedOut', handler)

// Позже, при очистке:
emitter.off('userLoggedOut', handler)

В React сочетайте это с cleanup-функцией в useEffect. В любом долгоживущем сервисе документируйте, какой компонент отвечает за удаление слушателей.

Заключение

Паттерн event map в TypeScript требует от вас одного определения интерфейса на старте. Взамен вы получаете проверку имён событий и структуры полезной нагрузки на этапе компиляции во всей кодовой базе — никаких молчаливых несоответствий, никаких callback’ов с типом any и полный IntelliSense для каждого слушателя. Для любой системы, где события пересекают границы модулей, этот компромисс стоит сделать немедленно.

Часто задаваемые вопросы

Да. Поскольку ваш типизированный emitter всё ещё расширяет или использует API EventEmitter из Node, он остаётся совместимым с любым кодом, который принимает экземпляр EventEmitter. Типовые ограничения применяются только на этапе компиляции и не создают накладных расходов в runtime, поэтому downstream-библиотеки продолжают работать как ожидается.

Определите значение события как void в вашей event map, например statusCheck: void. Затем настройте сигнатуру emit так, чтобы параметр payload был опциональным, когда тип — void. Это сохраняет чистоту в месте вызова — вы можете вызвать emit('statusCheck') без передачи аргумента.

TypeScript укажет на конфликт везде, где типы несовместимы. Самый безопасный подход — определить один общий интерфейс event map в общем модуле и импортировать его везде. Это поддерживает согласованность всех производителей и потребителей относительно одного источника истины.

Он может хорошо работать для лёгкого обмена сообщениями внутри страницы между микрофронтендами. Однако по мере роста числа независимых команд рассмотрите возможность сочетания паттерна с общим пакетом схем или шагом генерации кода, чтобы event map оставалась синхронизированной между отдельно развёртываемыми приложениями.

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