A type-safe event emitter keyed by an event-name-to-payload map
Published January 12, 2027
type EventMap = Record<string, unknown>;
export class TypedEmitter<Events extends EventMap> {
private listeners: { [K in keyof Events]?: Array<(payload: Events[K]) => void> } = {};
on<K extends keyof Events>(event: K, handler: (payload: Events[K]) => void): void {
(this.listeners[event] ??= []).push(handler);
}
emit<K extends keyof Events>(event: K, payload: Events[K]): void {
this.listeners[event]?.forEach((handler) => handler(payload));
}
}
export type AppEvents = { login: { userId: string }; logout: undefined };What
TypedEmitter<Events> takes a single type parameter — an object type mapping each event name to its payload type — and derives fully-typed on/emit methods from it. AppEvents declares that login events carry { userId: string } and logout events carry no payload; emitter.on("login", ...) and emitter.emit("login", ...) are then checked against exactly that.
Why it matters
A plain event emitter types on/emit as (event: string, ...args: any[]) => void — every event name is just a string and every payload is any, so a typo'd name and a wrong payload shape both compile and fail at runtime, if they fail at all. Writing an overload per event name works but doesn't scale — N events means N pairs of overloads to keep in sync by hand. Deriving both methods from one Events map type means adding a new event is a one-line change to the map, and every call site that uses it correctly gets checked automatically.
How it works
Events extends EventMapconstrains the type parameter to an object whose values can be anything, but whose keys are the fixed set of event names —keyof Eventsis that set.on's type parameterK extends keyof Eventsties theeventargument and thehandler's expected payload together: passing"login"forceshandler's parameter to beEvents["login"], not some other event's payload.emithas the same shape, so itspayloadargument must match whatevereventwas.this.listeners[event] ??= []lazily creates the array for an event on its firston()call, without needing to pre-populatelistenersfor every possible event up front.
Gotchas
emit("logout", undefined)needs an explicitundefinedargument even though there's no meaningful payload —logout: undefinedin the map still meansemittakes two arguments. A truly optional third argument for payload-less events needs a conditional type overEvents[K], which adds real complexity for a small ergonomics win.- Nothing here removes a listener. An
off(event, handler)method needs to compare handler references, which array-based storage supports, but it's easy to leak listeners if callers forget to call it.
Related
EventTargetand the DOM's native event system — untyped by default, and this pattern is essentially retrofitting what a lot of typed frontend state libraries build on top of it.- Mapped types with
as— a different application of "derive several things from one source map type."