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 { InjectionToken, inject, Provider, isDevMode } from '@angular/core'; import { WINDOW } from './window.token'; export interface AnalyticsConfig {
Layered config with an Angular InjectionToken
dependency-injection
configuration
factory-provider
Intermediate
8 steps
typescript
import { useCallback, useRef, useState } from "react"; type UploadZoneProps = { accept?: string[];
A drag-and-drop file upload zone in React
drag-and-drop
file-validation
controlled-state
Intermediate
9 steps
go
package middleware import ( "fmt"
How a panic-recovery middleware works in Gin
middleware
panic-recovery
error-reporting
Intermediate
8 steps
typescript
import { useState, useEffect, useRef, useCallback } from "react"; interface Suggestion { id: string;
A debounced autocomplete hook in React
debounce
custom-hooks
abortcontroller
Advanced
7 steps
rust
use axum::body::Bytes; use axum::http::{header, HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; use serde::Serialize;
Custom Axum responses with per-user ETags
etag
trait-implementation
generics
Intermediate
7 steps
typescript
import { Body, Controller, Ip, Post, UnauthorizedException } from '@nestjs/common'; import { Throttle, ThrottlerGuard } from '@nestjs/throttler'; import { UseGuards } from '@nestjs/common'; import { AuthService } from './auth.service';
Rate-limiting an auth flow in NestJS
rate-limiting
authentication
guards
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.