10LOC
TypeScriptadvanced

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 EventMap constrains the type parameter to an object whose values can be anything, but whose keys are the fixed set of event names — keyof Events is that set.
  • on's type parameter K extends keyof Events ties the event argument and the handler's expected payload together: passing "login" forces handler's parameter to be Events["login"], not some other event's payload.
  • emit has the same shape, so its payload argument must match whatever event was.
  • this.listeners[event] ??= [] lazily creates the array for an event on its first on() call, without needing to pre-populate listeners for every possible event up front.

Gotchas

  • emit("logout", undefined) needs an explicit undefined argument even though there's no meaningful payload — logout: undefined in the map still means emit takes two arguments. A truly optional third argument for payload-less events needs a conditional type over Events[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

  • EventTarget and 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."