| |

TypeScript 50 🔷 Type-Safe Event Emitters

An event emitter is a publish-subscribe mechanism: one part of a system announces that something happened, and other parts subscribe to be notified. Node.js has EventEmitter. The browser has addEventListener and CustomEvent. Angular has EventEmitter and signals. Every framework has some version of the pattern. The untyped versions share the same weakness: the event name is a string, the payload is any, and a typo in the event name or a mismatch between the emitted payload and the handler’s expectation is discovered at runtime. TypeScript can fix this. A type-safe event emitter uses a map of event names to payload types, and the compiler enforces that emitters send the right payload and listeners receive the right type. This chapter covers the design of a typed event emitter, the alternatives that JavaScript and TypeScript provide, the pitfalls of the untyped versions, and the patterns that make typed events maintainable.

Key point: A type-safe event emitter is built from an event map — an interface whose keys are event names and whose values are the payload types. The emitter is generic over the map, and its on, off, and emit methods are typed so that the event name and payload must match an entry in the map. emit accepts the event name and the corresponding payload; on accepts the event name and a handler typed to receive that payload. The compiler rejects unknown events and mismatched payloads, which turns a class of runtime bugs into compile-time errors.


The problem with untyped event emitters

Node.js’s EventEmitter is the canonical untyped emitter. It has been in the runtime since the early days and is used by countless libraries.

import { EventEmitter } from "events";

const emitter = new EventEmitter();

emitter.on("userCreated", (user) => {
  console.log(user.name);  // user is `any`
});

emitter.emit("userCreated", { name: "Alice" });

The user parameter in the handler is any. TypeScript does not know what userCreated carries, so it cannot check user.name or the shape of the object passed to emit. If the emitter is called with the wrong payload — or if the event name is misspelled — the error appears at runtime, not compile time.

Why the untyped form is dangerous. An event name typo produces a handler that never fires, with no error. A payload shape mismatch produces a runtime error deep inside a listener. A payload that is undefined because the emitter forgot to pass it crashes when the handler destructures. All three are common bugs, and all three are preventable with types.

Why the typed form is not automatic. TypeScript cannot infer the event map from usage. The developer must declare the map, and the emitter must be written (or chosen) to use it. The EventEmitter from Node does not accept a type parameter in its public API. Libraries like strict-event-emitter, eventemitter3 (with types), and mitt (with types) provide typed variants, and a custom emitter is also straightforward to write.

Why a custom emitter is often the right choice. A custom emitter is fifty lines of code, fully typed, and tailored to the project’s needs. It avoids a dependency and gives full control over the API. For projects that already use a library, the library’s typed emitter is fine. The choice is between a small amount of custom code and a dependency; both are reasonable.

Why the event map pattern is the standard. The map is an interface whose keys are event names and values are payload types. It is the single source of truth for the emitter’s contract, and it makes the emitter generic over the map. Adding a new event means adding a line to the map, and the compiler then enforces it everywhere. This is the pattern used by typed-emitter, mitt, and custom implementations.


The event map pattern

The event map is an interface that declares every event the emitter supports and the payload type for each.

interface UserEvents {
  userCreated: { id: string; name: string; email: string };
  userDeleted: { id: string };
  userUpdated: { id: string; changes: Partial<{ name: string; email: string }> };
}

Each key is an event name, and each value is the payload type for that event. The map is the contract: it lists every event the emitter knows about and what data each event carries.

Why the map is an interface and not a type alias. Either works, but an interface supports declaration merging, which allows different modules to extend the event map. A plugin that adds events can merge into the interface rather than redefining it. This is useful in modular systems where the event set is composed from multiple sources.

Why the payloads can be any type. The payload can be a primitive, an object, an array, a tuple, or void. An event with no payload uses void as its type, and the emitter can be written to allow or require the second argument to be omitted. An event with a single value uses that value’s type directly. An event with multiple values uses a tuple.

Why the map should be exhaustive. Every event the emitter can fire should be in the map. An event that is emitted but not declared is a compile error, which is exactly the point — the map is the registry, and the compiler enforces it.

Why the map should live near the emitter. The map is the emitter’s public contract. It should be exported alongside the emitter or defined in the same file. Consumers need to import it to type their handlers. Keeping it close makes the contract visible and easy to update.


Writing a type-safe emitter

The emitter is a class or interface generic over the event map. Its on, off, and emit methods are typed against the map.

type EventMap = Record<string, unknown>;

type EventKey<T extends EventMap> = string & keyof T;
type EventHandler<T> = (payload: T) => void;

class TypedEmitter<T extends EventMap> {
  private listeners: {
    [K in keyof T]?: Set<EventHandler<T[K]>>;
  } = {};

  on<K extends EventKey<T>>(event: K, handler: EventHandler<T[K]>): this {
    if (!this.listeners[event]) {
      this.listeners[event] = new Set();
    }
    this.listeners[event]!.add(handler);
    return this;
  }

  off<K extends EventKey<T>>(event: K, handler: EventHandler<T[K]>): this {
    this.listeners[event]?.delete(handler);
    return this;
  }

  emit<K extends EventKey<T>>(event: K, payload: T[K]): boolean {
    const handlers = this.listeners[event];
    if (!handlers || handlers.size === 0) return false;
    for (const handler of handlers) {
      handler(payload);
    }
    return true;
  }
}

The on and off methods accept an event name and a handler whose parameter is typed to the payload for that event. The emit method accepts the event name and a payload of the correct type. The compiler enforces all three.

Why EventKey<T> exists. K extends keyof T is the direct way to constrain the event name, but TypeScript has a subtle issue with generic constraints on keyof when the map might be extended. The EventKey<T> alias resolves this by intersecting string with keyof T. The result is a string literal union of the map’s keys, and it works reliably across TypeScript versions.

Why the listeners are stored in a mapped type. The internal listeners object is typed as { [K in keyof T]?: Set<EventHandler<T[K]>> }. This means the handler set for each event is typed to that event’s payload. The compiler does not allow a handler for one event to be stored under another event’s key.

Why emit returns a boolean. Node’s EventEmitter returns true if there were listeners and false otherwise. This is useful for diagnostics and for callers that want to know whether their event was heard. The return type is optional, and an emitter can return void instead.

Why the emitter returns this from on and off. Returning this enables chaining, which matches the fluent interface style from TypeScript 48. It also matches Node’s EventEmitter, which returns this from on and off. The choice is a matter of style; returning void would also be correct.

Why the internal implementation uses ! and as in some places. The types guarantee the invariants, but the runtime code sometimes needs a non-null assertion or a cast to satisfy the compiler. For example, this.listeners[event]! asserts that the set exists after the if check. These are localized to the emitter’s implementation and are safe because the logic guarantees them. Callers never see them.


Using the typed emitter

Using the emitter is the same as using an untyped one, except that the compiler checks everything.

interface AppEvents {
  login: { userId: string; timestamp: number };
  logout: { userId: string };
  error: { message: string; code: number };
}

const emitter = new TypedEmitter<AppEvents>();

// ✅ Correct usage
emitter.on("login", (payload) => {
  console.log(payload.userId, payload.timestamp);  // both typed
});

emitter.emit("login", { userId: "u1", timestamp: Date.now() });

// ❌ Unknown event
// emitter.emit("signup", { ... });  // error: 'signup' not in AppEvents

// ❌ Wrong payload
// emitter.emit("login", { userId: "u1" });  // error: timestamp missing

// ❌ Wrong handler parameter
// emitter.on("login", (payload: string) => {});  // error: payload is not string

Every error the untyped emitter would produce at runtime is now a compile error. The event name is checked against the map, the payload is checked against the payload type, and the handler’s parameter is inferred from the map.

Why the handler parameter is inferred. The on method’s signature is on<K extends EventKey<T>>(event: K, handler: EventHandler<T[K]>). When K is inferred as the literal "login", T[K] is AppEvents["login"], which is { userId: string; timestamp: number }. The handler’s parameter is inferred as that type, and the developer does not need to annotate it.

Why the payload must be provided. The emit method requires the payload argument. An event with void payload allows the second argument to be omitted only if the method is overloaded to do so. A common pattern is a conditional argument: emit<K extends EventKey<T>>(event: K, ...args: T[K] extends void ? [] : [T[K]]). This makes the payload optional for void events and required for others.

Why the emitter can be made to support once. Adding a once method is straightforward: register a handler that removes itself after firing. The typing is the same as on, and the implementation is a small wrapper. Similarly, removeAllListeners and listenerCount can be added without affecting the type contract.


The conditional argument for void payloads

An event with no payload is awkward if the emitter always requires a second argument. The fix is a conditional tuple that either requires the payload or omits it.

type EventArgs<T> = T extends void ? [] : [T];

class TypedEmitter<T extends EventMap> {
  emit<K extends EventKey<T>>(event: K, ...args: EventArgs<T[K]>): boolean {
    const payload = args[0] as T[K];
    // ...
    return true;
  }
}

The EventArgs<T> type is a tuple that is empty when T is void and a single-element tuple otherwise. The emit method uses the spread, which makes the payload optional for void events and required for others.

interface Events {
  ping: void;
  message: { text: string };
}

emitter.emit("ping");                    // ✅ no payload
emitter.emit("message", { text: "hi" }); // ✅ payload required

This is the correct way to support void events while keeping the payload required for events that have one. The conditional tuple is a small type-level pattern that improves the emitter’s ergonomics significantly.

Why the void check works. When T[K] is void, the conditional T[K] extends void ? [] : [T[K]] evaluates to [], and the spread produces no argument. When T[K] is anything else, it evaluates to [T[K]], and the spread produces exactly one argument of that type. The conditional distributes if T[K] is a union, but in practice the payload is a single type.

Why undefined is different from void. An event with payload undefined is not the same as an event with no payload. void means “no meaningful value,” while undefined is a value that happens to be undefined. The conditional extends void treats them the same, which is usually fine but worth being aware of. If the distinction matters, the check can be T extends undefined | void.


Alternatives: mitt, eventemitter3, and framework emitters

Several libraries provide typed event emitters, and each has a different design. Knowing the alternatives clarifies the tradeoffs.

mitt is a tiny (200 bytes) event emitter with excellent TypeScript support. Its API is on, off, and emit, and it types the event map the same way the custom emitter does.

import mitt, { Emitter } from "mitt";

type Events = {
  login: { userId: string };
  logout: { userId: string };
};

const emitter: Emitter<Events> = mitt<Events>();
emitter.on("login", (payload) => console.log(payload.userId));
emitter.emit("login", { userId: "u1" });

eventemitter3 is a faster drop-in replacement for Node’s EventEmitter. It has types via @types/eventemitter3 or a typed variant, and the API matches Node’s. It is a good choice when performance matters and the Node API is familiar.

Node’s EventEmitter can be made typed with a wrapper. The wrapper is generic over the event map and casts to the underlying EventEmitter, providing the type safety at the surface.

class TypedEventEmitter<T extends EventMap> {
  private readonly emitter = new EventEmitter();
  on<K extends EventKey<T>>(event: K, handler: EventHandler<T[K]>): this {
    this.emitter.on(event as string, handler as (...args: unknown[]) => void);
    return this;
  }
  // ...
}

Angular’s EventEmitter is a subclass of RxJS Subject and is used for @Output() properties. It is typed, and the payload type is the type parameter. It is a specialized emitter for component outputs, not a general-purpose one, but the type safety is the same principle.

Signals are the newest alternative. Angular signals, Preact signals, and similar APIs provide a reactive value that notifies subscribers when it changes. They are not event emitters in the classic sense — there is no event name and no payload — but they serve a similar role for state-change notification. For state, signals are often the better tool. For events with distinct names and payloads, an emitter is still the right choice.

Why choose a library over a custom emitter. A library is tested, documented, and has an established API. A custom emitter is fifty lines and tailored to the project. The decision hinges on whether the project already has a dependency that provides an emitter and whether the custom implementation would need to grow beyond the basics. For most projects, mitt or a small custom emitter is the right choice.


Complete Example Session

// ============================================
// PART 1: THE EVENT MAP
// ============================================

interface UserEvents {
  userCreated: { id: string; name: string; email: string };
  userDeleted: { id: string };
  sessionExpired: void;
}

// ============================================
// PART 2: THE TYPED EMITTER
// ============================================

type EventMap = Record<string, unknown>;
type EventKey<T extends EventMap> = string & keyof T;
type EventHandler<T> = (payload: T) => void;
type EventArgs<T> = T extends void ? [] : [T];

class TypedEmitter<T extends EventMap> {
  private listeners: {
    [K in keyof T]?: Set<EventHandler<T[K]>>;
  } = {};

  on<K extends EventKey<T>>(event: K, handler: EventHandler<T[K]>): this {
    if (!this.listeners[event]) {
      this.listeners[event] = new Set();
    }
    this.listeners[event]!.add(handler);
    return this;
  }

  off<K extends EventKey<T>>(event: K, handler: EventHandler<T[K]>): this {
    this.listeners[event]?.delete(handler);
    return this;
  }

  once<K extends EventKey<T>>(event: K, handler: EventHandler<T[K]>): this {
    const wrapper = (payload: T[K]) => {
      this.off(event, wrapper);
      handler(payload);
    };
    return this.on(event, wrapper);
  }

  emit<K extends EventKey<T>>(event: K, ...args: EventArgs<T[K]>): boolean {
    const handlers = this.listeners[event];
    if (!handlers || handlers.size === 0) return false;
    const payload = args[0] as T[K];
    for (const handler of handlers) {
      handler(payload);
    }
    return true;
  }

  listenerCount<K extends EventKey<T>>(event: K): number {
    return this.listeners[event]?.size ?? 0;
  }

  removeAllListeners<K extends EventKey<T>>(event?: K): this {
    if (event) {
      delete this.listeners[event];
    } else {
      this.listeners = {};
    }
    return this;
  }
}

// ============================================
// PART 3: BASIC USAGE
// ============================================

const emitter = new TypedEmitter<UserEvents>();

emitter.on("userCreated", (payload) => {
  console.log(payload.id, payload.name, payload.email);
});

emitter.emit("userCreated", {
  id: "u1",
  name: "Alice",
  email: "alice@example.com",
});

// ============================================
// PART 4: VOID EVENTS
// ============================================

emitter.on("sessionExpired", () => {
  console.log("Session expired");
});

emitter.emit("sessionExpired");   // ✅ no payload required

// ============================================
// PART 5: ONCE
// ============================================

emitter.once("userDeleted", (payload) => {
  console.log("Deleted:", payload.id);
});

emitter.emit("userDeleted", { id: "u1" });  // fires
emitter.emit("userDeleted", { id: "u2" });  // does not fire

// ============================================
// PART 6: WRONG USAGE — COMPILE ERRORS
// ============================================

// emitter.emit("userSignedUp", { ... });
// ❌ Argument of type '"userSignedUp"' is not assignable to parameter of type 'EventKey<UserEvents>'

// emitter.emit("userCreated", { id: "u1" });
// ❌ Property 'name' is missing

// emitter.on("userDeleted", (payload: string) => {});
// ❌ Type 'string' is not assignable to type '{ id: string }'

// emitter.emit("sessionExpired", { extra: true });
// ❌ Expected 1 argument, but got 2

// ============================================
// PART 7: CLEANUP
// ============================================

const handler = (payload: UserEvents["userCreated"]) => {
  console.log(payload.id);
};

emitter.on("userCreated", handler);
emitter.off("userCreated", handler);

console.log(emitter.listenerCount("userCreated")); // 0

// ============================================
// PART 8: USING mitt INSTEAD
// ============================================

import mitt, { Emitter } from "mitt";

type AppEvents = {
  login: { userId: string };
  logout: { userId: string };
};

const mittEmitter: Emitter<AppEvents> = mitt<AppEvents>();

mittEmitter.on("login", (payload) => {
  console.log(payload.userId);  // typed
});

mittEmitter.emit("login", { userId: "u1" });

// ============================================
// PART 9: A DOM-STYLE ALTERNATIVE
// ============================================

interface DomEvents {
  click: MouseEvent;
  keydown: KeyboardEvent;
}

class TypedTarget<T extends Record<string, Event>> {
  private listeners = new Map<keyof T, Set<(event: Event) => void>>();

  addEventListener<K extends keyof T>(
    type: K,
    listener: (event: T[K]) => void,
  ): void {
    if (!this.listeners.has(type)) {
      this.listeners.set(type, new Set());
    }
    this.listeners.get(type)!.add(listener as (event: Event) => void);
  }

  dispatchEvent<K extends keyof T>(type: K, event: T[K]): void {
    this.listeners.get(type)?.forEach((l) => l(event));
  }
}

// ============================================
// PART 10: WHAT NOT TO DO
// ============================================

// Don't use `any` for the payload
// const emitter = new EventEmitter();  // payload is any

// Don't use string literals without a map
// emitter.on("someRandomEvent", () => {});  // no type check

// Don't forget to type the handler parameter
// The compiler infers it — don't fight it.

// Don't ignore the void payload case
// An event with no payload should not require a second argument.

The ten parts show the map, the emitter, correct usage, void events, once, compile errors, cleanup, alternatives, a DOM-style variant, and the anti-patterns.


Quick Reference

The Event Map

ElementExample
Event name"userCreated"
Payload type{ id: string; name: string }
No payloadvoid
Single valuestring
Multiple values[string, number]

Emitter Methods

MethodSignaturePurpose
on(event, handler) => thisSubscribe
off(event, handler) => thisUnsubscribe
once(event, handler) => thisSubscribe once
emit(event, ...args) => booleanFire
listenerCount(event) => numberCount subscribers
removeAllListeners(event?) => thisClear

Type Helpers

HelperPurpose
EventMapBase constraint for maps
EventKey<T>String union of event names
EventHandler<T>Handler type
EventArgs<T>Conditional payload tuple

Void Payload Handling

Payloademit call
voidemit("event")
stringemit("event", "value")
{ x: 1 }emit("event", { x: 1 })
[string, number]emit("event", "a", 1) — if spread

Libraries

LibrarySizeStyle
Custom~50 linesFull control
mitt~200 BMinimal, typed
eventemitter3SmallNode-compatible
Node EventEmitterBuilt-inUntyped (wrap for types)
Angular EventEmitterBuilt-inComponent outputs
SignalsFrameworkState change

Best Practices

✅ Do This:

// Define a clear event map
interface AppEvents {
  login: { userId: string };
  logout: { userId: string };
}                                                              // ✅

// Use void for events with no payload
interface Events { ping: void; message: string; }              // ✅

// Use the conditional tuple for void payloads
type EventArgs<T> = T extends void ? [] : [T];                 // ✅

// Return `this` from on/off for chaining
on<K extends EventKey<T>>(event: K, handler: EventHandler<T[K]>): this { ... } // ✅

// Use a Set for listeners to prevent duplicates
private listeners: { [K in keyof T]?: Set<EventHandler<T[K]>> } = {}; // ✅

// Consider mitt for a small, tested alternative
import mitt from "mitt";                                       // ✅

❌ Don’t Do This:

// Don't use Node's untyped EventEmitter directly
const emitter = new EventEmitter();  // payload is any           // ⚠️

// Don't pass handlers with `any` parameters
emitter.on("event", (payload: any) => {});                      // ⚠️

// Don't forget to remove listeners when they are no longer needed
// Memory leaks in long-lived emitters                            // ⚠️

// Don't require a payload for void events
emit("ping", undefined);  // awkward                            // ⚠️

// Don't use string event names without a map
emitter.on("someEvent", () => {});  // no type safety           // ⚠️

Common Pitfalls

PitfallProblemSolution
Missing event in mapCompile error on emit/onAdd to the map
Wrong payload typeCompile errorMatch the map
Handler parameter anyNo inferenceLet the compiler infer
Void payload requiredAwkward callUse EventArgs<T>
Listener leakHandlers accumulateoff or removeAllListeners
Duplicate handlersFired twiceUse a Set
Emitter not genericNo type safetyTypedEmitter<T>
Map extended at runtimeNot reflected in typesUse declaration merging

Real-World Examples

1. Application events

interface AppEvents {
  login: { userId: string };
  logout: { userId: string };
  error: { message: string; code: number };
}

2. Component output

@Output() saved = new EventEmitter<User>();

3. HTTP request events

interface HttpEvents {
  request: { url: string; method: string };
  response: { status: number; body: unknown };
  error: { status: number; message: string };
}

4. WebSocket messages

interface WsEvents {
  open: void;
  message: { type: string; data: unknown };
  close: { code: number; reason: string };
}

5. Form validation events

interface FormEvents {
  valid: void;
  invalid: { errors: Record<string, string> };
  submitted: { values: Record<string, unknown> };
}

6. Analytics tracking

interface AnalyticsEvents {
  pageView: { path: string };
  click: { element: string; x: number; y: number };
}

7. State machine transitions

interface MachineEvents {
  transition: { from: string; to: string };
  error: { message: string };
}

8. File upload events

interface UploadEvents {
  progress: { loaded: number; total: number };
  complete: { url: string };
  error: { message: string };
}

9. Auth events

interface AuthEvents {
  login: { userId: string; token: string };
  logout: void;
  tokenExpired: void;
}

10. Domain events in DDD

interface OrderEvents {
  orderPlaced: { orderId: string; total: number };
  orderShipped: { orderId: string; tracking: string };
  orderCancelled: { orderId: string; reason: string };
}

Visual: The Event Map

┌──────────────────────────────────────────────────────────┐
│  interface AppEvents {                                   │
│    login:   { userId: string; timestamp: number };       │
│    logout:  { userId: string };                          │
│    ping:    void;                                        │
│  }                                                       │
│                                                          │
│  ┌──────────────────┬────────────────────────────┐       │
│  │  event name      │  payload type              │       │
│  ├──────────────────┼────────────────────────────┤       │
│  │  "login"         │  { userId, timestamp }     │       │
│  │  "logout"        │  { userId }                │       │
│  │  "ping"          │  (none)                    │       │
│  └──────────────────┴────────────────────────────┘       │
│                                                          │
│  The map is the contract.                                │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: Type Flow Through the Emitter

┌──────────────────────────────────────────────────────────┐
│  emitter.on("login", handler)                            │
│       │                                                  │
│       │  K inferred as "login"                           │
│       │  T[K] = AppEvents["login"]                       │
│       │       = { userId: string; timestamp: number }    │
│       ▼                                                  │
│  handler: (payload: { userId: string; timestamp: number })│
│       │                                                  │
│       │  payload.userId  ✅ typed                        │
│       │  payload.timestamp  ✅ typed                     │
│       ▼                                                  │
│  Compiler enforces the handler signature.                │
│                                                          │
├──────────────────────────────────────────────────────────┤
│  emitter.emit("login", { userId: "u1", timestamp: 1 })   │
│       │                                                  │
│       │  K inferred as "login"                           │
│       │  payload must be AppEvents["login"]              │
│       ▼                                                  │
│  ✅ compiles                                             │
│                                                          │
│  emitter.emit("login", { userId: "u1" })                 │
│       │                                                  │
│       ▼                                                  │
│  ❌ Property 'timestamp' is missing                      │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: Compile-Time vs Runtime Errors

┌──────────────────────────────────────────────────────────┐
│  UNTYPED EventEmitter                                    │
│                                                          │
│  emitter.emit("userCreatd", user);  // typo             │
│    → No error. Handler never fires. Silent bug.          │
│                                                          │
│  emitter.emit("userCreated", { id: "1" });               │
│    → No error. Handler crashes on user.name.             │
│                                                          │
│  Errors surface at runtime, far from the cause.          │
│                                                          │
├──────────────────────────────────────────────────────────┤
│  TYPED TypedEmitter<AppEvents>                           │
│                                                          │
│  emitter.emit("userCreatd", user);                       │
│    → ❌ 'userCreatd' is not in AppEvents                 │
│                                                          │
│  emitter.emit("userCreated", { id: "1" });               │
│    → ❌ Property 'name' is missing                       │
│                                                          │
│  Errors surface at compile time, at the call site.       │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: Void Payload Handling

┌──────────────────────────────────────────────────────────┐
│  WITHOUT conditional tuple                               │
│                                                          │
│  emit<K extends EventKey<T>>(event: K, payload: T[K]): … │
│                                                          │
│  emitter.emit("ping", undefined);                        │
│    ↑ awkward — must pass a value for a void event        │
│                                                          │
├──────────────────────────────────────────────────────────┤
│  WITH conditional tuple                                  │
│                                                          │
│  type EventArgs<T> = T extends void ? [] : [T];          │
│  emit<K extends EventKey<T>>(event: K, ...args: EventArgs<T[K]>): …│
│                                                          │
│  emitter.emit("ping");                                   │
│    ✅ no payload — clean                                 │
│                                                          │
│  emitter.emit("login", { userId: "u1" });                │
│    ✅ payload required — enforced                        │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: The Listener Lifecycle

┌──────────────────────────────────────────────────────────┐
│  emitter.on("login", handler)                            │
│       │                                                  │
│       ▼                                                  │
│  listeners["login"] = Set { handler }                    │
│                                                          │
│  emitter.emit("login", payload)                          │
│       │                                                  │
│       ▼                                                  │
│  for (handler of listeners["login"]) handler(payload)    │
│                                                          │
│  emitter.off("login", handler)                           │
│       │                                                  │
│       ▼                                                  │
│  listeners["login"].delete(handler)                      │
│                                                          │
│  emitter.removeAllListeners("login")                     │
│       │                                                  │
│       ▼                                                  │
│  delete listeners["login"]                               │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: When to Use an Emitter vs a Signal

┌──────────────────────────────────────────────────────────┐
│  What are you notifying about?                           │
│       │                                                  │
│       ├── A state change (value X is now Y)              │
│       │      └── Signal                                  │
│       │          - Reactive value                        │
│       │          - Automatic subscription cleanup        │
│       │                                                  │
│       ├── A discrete event (something happened)          │
│       │      └── Event emitter                           │
│       │          - Named events                          │
│       │          - Typed payloads                        │
│       │                                                  │
│       └── A one-time notification                        │
│              └── Event emitter with `once`               │
│                                                          │
└──────────────────────────────────────────────────────────┘

Summary

ItemValue
Event mapInterface of name → payload
EmitterGeneric over the map
onSubscribe with typed handler
offUnsubscribe
onceSubscribe once
emitFire with typed payload
Void payloadEventArgs<T> conditional tuple
Type helperEventKey<T>, EventHandler<T>
Runtime storageSet per event
Alternativemitt, eventemitter3, signals

Key takeaways:

  • An event map is the single source of truth — an interface whose keys are event names and whose values are payload types
  • The emitter is generic over the map — TypedEmitter<AppEvents> binds the on, off, and emit methods to the map’s contract
  • The compiler enforces event names and payloads — typos and mismatches become compile errors instead of silent runtime bugs
  • Handler parameters are inferred from the map, so listeners do not need annotations
  • Void payloads need the conditional tuple — EventArgs<T> = T extends void ? [] : [T] makes the payload optional for void events and required for others
  • The listener storage is a Set per event — this prevents duplicate registrations and makes off a simple delete
  • once is a thin wrapper around on that removes the handler after the first fire
  • The runtime stores handlers in a mapped type — { [K in keyof T]?: Set<EventHandler<T[K]>> } ties the storage to the map
  • Libraries like mitt provide the same pattern in a small package, and are a reasonable alternative to a custom emitter
  • Signals and emitters serve different purposes — signals are for state that changes and is observed; emitters are for discrete events with names and payloads

Remember: An untyped event emitter is a string-keyed map of any-typed handlers. A typed event emitter is a map of event names to payload types, and the compiler enforces the contract everywhere. The pattern is small — an interface, a generic class, a few methods — and it eliminates a class of bugs that are otherwise invisible until they fire. For events that carry data, the typed emitter is the right tool.


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!