typescript
46 lines · 7 steps
A type-safe event bus in TypeScript
A generic MessageBus maps event names to handler sets, keeping payload types in sync with each event key.
Explained by
highlit
1type EventMap = Record<string, unknown>;
2
3type Handler<T> = (payload: T) => void;
4
5export class MessageBus<Events extends EventMap> {
6 private handlers = new Map<keyof Events, Set<Handler<unknown>>>();
7
8 on<K extends keyof Events>(event: K, handler: Handler<Events[K]>): () => void {
9 let set = this.handlers.get(event);
10 if (!set) {
11 set = new Set();
12 this.handlers.set(event, set);
13 }
14 set.add(handler as Handler<unknown>);
15 return () => this.off(event, handler);
16 }
17
18 once<K extends keyof Events>(event: K, handler: Handler<Events[K]>): () => void {
19 const wrapped: Handler<Events[K]> = (payload) => {
20 off();
21 handler(payload);
22 };
23 const off = this.on(event, wrapped);
24 return off;
25 }
26
27 off<K extends keyof Events>(event: K, handler: Handler<Events[K]>): void {
28 const set = this.handlers.get(event);
29 if (!set) return;
30 set.delete(handler as Handler<unknown>);
31 if (set.size === 0) this.handlers.delete(event);
32 }
33
34 emit<K extends keyof Events>(event: K, payload: Events[K]): void {
35 const set = this.handlers.get(event);
36 if (!set) return;
37 for (const handler of [...set]) {
38 (handler as Handler<Events[K]>)(payload);
39 }
40 }
41
42 clear(event?: keyof Events): void {
43 if (event === undefined) this.handlers.clear();
44 else this.handlers.delete(event);
45 }
46}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Indexed access types like Events[K] let one generic parameter enforce that every event name carries its matching payload type.
- 2Storing subscriptions in a Set and returning an unsubscribe closure gives callers a clean, self-contained teardown handle.
- 3Iterating over a snapshot copy of listeners protects against mutation while handlers fire.
Related explainers
typescript
import { registerLocaleData } from '@angular/common'; import localeFr from '@angular/common/locales/fr'; import localeFrExtra from '@angular/common/locales/extra/fr'; import localeDe from '@angular/common/locales/de';
Locale-aware bootstrapping in Angular
i18n
localization
dependency-injection
Intermediate
8 steps
typescript
import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import * as Joi from 'joi';
Validating env config at boot in NestJS
configuration
schema-validation
environment-variables
Intermediate
8 steps
javascript
function evaluate(expression) { const tokens = tokenize(expression); let pos = 0;
Building a recursive descent calculator
parsing
recursion
operator-precedence
Intermediate
8 steps
typescript
import { Inject, Injectable, Logger } from '@nestjs/common'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; import { InjectRepository } from '@nestjs/typeorm';
A cache-aside country lookup in NestJS
cache-aside
dependency-injection
batch-lookup
Intermediate
8 steps
typescript
import { Injectable, effect, signal, computed } from '@angular/core'; interface Preferences { theme: 'light' | 'dark';
A signal-based preferences store in Angular
signals
state-management
persistence
Intermediate
7 steps
typescript
import { useEffect, useState } from "react"; interface Section { id: string;
Building a scroll-spy hook in React
custom-hooks
intersectionobserver
dom-observation
Intermediate
8 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/a-type-safe-event-bus-in-typescript-explained-typescript-c0f4/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.