Building a Type-Safe Event Bus in TypeScript with Discriminated Unions
Why a Typed Event Bus Matters
In a medium‑size frontend codebase I work on, several feature modules need to talk to each other without creating a tangled web of direct imports. A lightweight event bus solves the coupling problem, but a plain EventEmitter quickly becomes a source of runtime bugs — listeners expect a payload shape that the emitter never guarantees. TypeScript can enforce that contract at compile time if we model events as a discriminated union and make the bus generic over that union.
The Core Types
Start by defining every event your app can emit. Each variant carries a type literal and a payload that matches that type.
type AppEvent =
| { type: 'user:login'; payload: { userId: string; roles: string[] } }
| { type: 'user:logout'; payload: { userId: string } }
| { type: 'notification:show'; payload: { message: string; level: 'info' | 'warn' | 'error' } }
| { type: 'data:refresh'; payload: { resource: string; params?: Record } };
The type field is the discriminant. TypeScript narrows the payload automatically inside a switch or if block, giving you full autocomplete and exhaustiveness checking.
A Minimal, Fully‑Typed Bus
Below is a production‑ready implementation that weighs less than 30 lines. It uses a Map of listener arrays keyed by the event’s type string, so lookup stays O(1).
class EventBus {
private listeners = new Map void>>();
/** Subscribe to a specific event type */
on(
type: K,
handler: (payload: Extract['payload']) => void
): () => void {
const set = this.listeners.get(type) ?? new Set();
set.add(handler as (payload: any) => void);
this.listeners.set(type, set);
// Return an unsubscribe function
return () => {
set.delete(handler as (payload: any) => void);
if (set.size === 0) this.listeners.delete(type);
};
}
/** Emit an event — payload is checked against the union */
emit(event: Extract): void {
const set = this.listeners.get(event.type);
if (!set) return;
for (const fn of set) fn(event.payload);
}
}
// Singleton instance for the whole app
export const bus = new EventBus();
Notice the on signature: the handler receives Extract. TypeScript resolves that to the exact payload shape for the given K. If you try to subscribe to 'user:login' with a handler expecting { message: string }, the compiler complains.
Real‑World Usage
In a React component that shows toast notifications, you only care about notification:show. The component subscribes on mount and cleans up on unmount:
function useToasts() {
const [toasts, setToasts] = useState>([]);
useEffect(() => {
const unsubscribe = bus.on('notification:show', ({ message, level }) => {
const id = Date.now();
setToasts(prev => [...prev, { id, message, level }]);
// Auto‑dismiss after 5 s
setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), 5000);
});
return unsubscribe;
}, []);
return toasts;
}
Because bus.on is typed, the destructured { message, level } is guaranteed to exist and have the correct types. No any casts, no runtime validation needed.
Emitting from Anywhere
A service that fetches user data can fire a login event without importing UI code:
async function login(credentials: Credentials) {
const { userId, roles } = await api.authenticate(credentials);
bus.emit({ type: 'user:login', payload: { userId, roles } });
// Other listeners (analytics, feature flags) react automatically
}
If you later add a new field to the login payload, TypeScript will flag every emit call and every listener until you update them — a safety net that plain strings can’t provide.
Exhaustiveness Checks for Future‑Proofing
When you handle events in a central reducer or saga, a switch on event.type gives you compile‑time exhaustiveness:
function handleEvent(event: AppEvent) {
switch (event.type) {
case 'user:login':
// event.payload is { userId: string; roles: string[] }
break;
case 'user:logout':
// event.payload is { userId: string }
break;
case 'notification:show':
// event.payload is { message: string; level: 'info' | 'warn' | 'error' }
break;
case 'data:refresh':
// event.payload is { resource: string; params?: Record }
break;
default:
const _exhaustive: never = event; // error if a new type is added
break;
}
}
Adding a new variant to AppEvent instantly produces a compile error in handleEvent, reminding you to update the logic.
When to Reach for This Pattern
- Decoupled modules — feature packages, micro‑frontends, or plugins that shouldn’t import each other.
- Cross‑cutting concerns — logging, analytics, feature‑flag updates, or UI notifications.
- Testing — you can swap the bus with a test double that records emitted events, making integration tests trivial.
Tip: Keep the event union in a single
events.tsfile. It becomes the source of truth for the whole application’s contract.
Closing Thoughts
A typed event bus is a small investment that pays off every time a new feature needs to react to something happening elsewhere. The discriminated union gives you autocomplete, refactor‑safe payloads, and exhaustiveness checking — all without a heavyweight framework. Next time you reach for a global window.dispatchEvent or a loose Subject, consider spending ten minutes to wrap it in the pattern above. Your future self (and the CI pipeline) will thank you.