Проверять имена событий и их payload на компиляции в наследнике EventEmitter
Шина сервиса наследует Node-класс EventEmitter. Унаследованные on и emit принимают имя string | symbol и аргументы any[], поэтому и опечатка в имени, и слушатель, чьи аргументы разошлись с отправителем, компилируются и молча падают в рантайме.
Требования: имя ограничено ключами AppEvents; параметры слушателя и аргументы emit берутся из кортежа именно этого имени; emit("shutdown") не принимает больше ни одного аргумента; никакого any и никаких приведений на местах вызова; никакого глобального дополнения EventEmitter; рассылка по-прежнему идёт через базовый класс, поэтому поведение в рантайме не меняется.
import { EventEmitter } from "node:events";
interface AppEvents {
"user:created": [user: { id: string }];
"job:failed": [jobId: string, error: Error];
"shutdown": [];
}
class TypedEmitter<E extends Record<keyof E, unknown[]>> extends EventEmitter {
// ваш код здесь
}
const bus = new TypedEmitter<AppEvents>();
Допишите реализацию.
Сделайте наследника обобщённым по реестру «имя события → кортеж аргументов» и перекройте on и emit обобщёнными по K extends keyof E: event: K, ...args: E[K]. Неизвестное имя или неверный payload не компилируются, а приведение остаётся внутри класса.
- ✗Считать, что перекрытие не вправе сузить типы параметров базовых
onиemit - ✗Типизировать один общий для всех событий payload вместо кортежа на каждое имя
- ✗Глобально дополнять
EventEmitter, из-за чего все emitter в процессе делят одну карту событий
- →Как согласовать
onceиoff, чтобы снятый по ссылке слушатель по-прежнему проходил проверку типов? - →Что изменится, если имя сопоставлено одному объекту-payload, а не кортежу аргументов?
Решение
import { EventEmitter } from "node:events";
interface AppEvents {
"user:created": [user: { id: string }];
"job:failed": [jobId: string, error: Error];
"shutdown": [];
}
type Listener<A extends unknown[]> = (...args: A) => void;
class TypedEmitter<E extends Record<keyof E, unknown[]>> extends EventEmitter {
override on<K extends keyof E & string>(event: K, listener: Listener<E[K]>): this {
return super.on(event, listener as Listener<unknown[]>); // ← единственное приведение
}
override once<K extends keyof E & string>(event: K, listener: Listener<E[K]>): this {
return super.once(event, listener as Listener<unknown[]>);
}
override off<K extends keyof E & string>(event: K, listener: Listener<E[K]>): this {
return super.off(event, listener as Listener<unknown[]>);
}
override emit<K extends keyof E & string>(event: K, ...args: E[K]): boolean {
return super.emit(event, ...args);
}
}
Использование:
const bus = new TypedEmitter<AppEvents>();
bus.on("job:failed", (jobId, error) => {
// ^ "user:created" | "job:failed" | "shutdown"
// ^ jobId: string, error: Error — выведены, без аннотаций
});
bus.emit("shutdown"); // OK — кортеж пустой
bus.emit("user:created", { id: "u1" }); // OK
bus.emit("user:created"); // Expected 2 arguments, but got 1
bus.emit("user:crated", { id: "u1" }); // Argument of type '"user:crated"' is not assignable
Почему перекрытие вообще разрешено
Базовый on объявлен как on(event: string | symbol, listener: (...args: any[]) => void): this, а наследник сужает имя до K. Это проходит потому, что параметры методов проверяются бивариантно: strictFunctionTypes ужесточает проверку только для свойств функционального типа, но не для объявленных методов. Ровно на этом послаблении и держится весь приём.
Небезопасное место при этом ровно одно — приведение слушателя внутри класса. Оно честное: базовый EventEmitter действительно рассылает unknown[], а гарантию «кортеж соответствует имени» держат сигнатуры on и emit, через которые обязан пройти каждый вызывающий.
Ограничение E extends Record<keyof E, unknown[]> не даёт объявить событие, чей payload не кортеж, — иначе ...args: E[K] не скомпилировался бы.