Observer Pattern in TypeScript: EventEmitter
The Observer pattern is one of the most widely used behavioral patterns in software development. At its core, a subject maintains a list of dependents (observers) and automatically notifies them when...
Key Insights
- The Observer pattern decouples components by letting subjects notify observers of changes without knowing their implementation details—essential for scalable event-driven architectures.
- TypeScript generics transform a basic EventEmitter into a fully type-safe system where event names and payloads are validated at compile time, eliminating an entire class of runtime errors.
- Memory leaks from forgotten subscriptions are the most common Observer pattern pitfall; always implement cleanup patterns using dispose methods or AbortController.
Introduction to the Observer Pattern
The Observer pattern is one of the most widely used behavioral patterns in software development. At its core, a subject maintains a list of dependents (observers) and automatically notifies them when its state changes. This creates a one-to-many dependency where the subject doesn’t need to know anything about the observers beyond their interface.
You encounter this pattern constantly, whether you realize it or not. DOM event listeners follow it. React’s state updates trigger component re-renders through it. WebSocket message handlers implement it. Microservices communicate through message brokers built on it. Understanding how to implement a robust EventEmitter gives you insight into the foundation of reactive programming.
The pattern shines when you need loose coupling between components. Instead of a payment processor directly calling inventory, shipping, and notification services, it emits a paymentCompleted event. Each service subscribes independently, and adding new subscribers requires zero changes to the payment code.
Core Components and TypeScript Interfaces
Before writing implementation code, define the contracts. TypeScript interfaces let you establish type-safe boundaries that catch errors at compile time rather than runtime.
// Base event structure with discriminated union support
interface BaseEvent {
readonly timestamp: number;
}
// Observer function signature - receives the event payload
type Observer<T> = (payload: T) => void;
// Subject interface - anything that can be observed
interface Subject<TEventMap extends Record<string, unknown>> {
on<K extends keyof TEventMap>(event: K, observer: Observer<TEventMap[K]>): void;
off<K extends keyof TEventMap>(event: K, observer: Observer<TEventMap[K]>): void;
emit<K extends keyof TEventMap>(event: K, payload: TEventMap[K]): void;
}
// Example event map using discriminated unions
interface AppEvents {
userLoggedIn: { userId: string; email: string };
userLoggedOut: { userId: string };
error: { code: number; message: string };
dataLoaded: { items: unknown[]; total: number };
}
The generic TEventMap constraint is crucial. It ensures that when you call emit('userLoggedIn', payload), TypeScript verifies the payload matches { userId: string; email: string }. Typos in event names become compile errors, not silent bugs.
Building a Basic EventEmitter Class
Start with a straightforward implementation that handles the core functionality: subscribing, unsubscribing, and emitting events.
class EventEmitter<TEventMap extends Record<string, unknown>>
implements Subject<TEventMap> {
private listeners = new Map<keyof TEventMap, Set<Observer<unknown>>>();
on<K extends keyof TEventMap>(event: K, observer: Observer<TEventMap[K]>): void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(observer as Observer<unknown>);
}
off<K extends keyof TEventMap>(event: K, observer: Observer<TEventMap[K]>): void {
const observers = this.listeners.get(event);
if (observers) {
observers.delete(observer as Observer<unknown>);
if (observers.size === 0) {
this.listeners.delete(event);
}
}
}
emit<K extends keyof TEventMap>(event: K, payload: TEventMap[K]): void {
const observers = this.listeners.get(event);
if (observers) {
observers.forEach(observer => observer(payload));
}
}
// Utility: check if event has any listeners
hasListeners<K extends keyof TEventMap>(event: K): boolean {
return (this.listeners.get(event)?.size ?? 0) > 0;
}
// Utility: get listener count for debugging
listenerCount<K extends keyof TEventMap>(event: K): number {
return this.listeners.get(event)?.size ?? 0;
}
}
Using Set instead of an array for observers provides O(1) removal and automatically prevents duplicate subscriptions. The Map keyed by event name keeps different event types isolated.
Adding Advanced Features
A production EventEmitter needs more than the basics. Single-use listeners, wildcard subscriptions, and proper error handling transform it from a toy into a tool.
class AdvancedEventEmitter<TEventMap extends Record<string, unknown>>
extends EventEmitter<TEventMap> {
private onceWrappers = new WeakMap<Observer<unknown>, Observer<unknown>>();
private wildcardListeners = new Set<Observer<{ event: keyof TEventMap; payload: unknown }>>();
// Subscribe to a single occurrence, then auto-unsubscribe
once<K extends keyof TEventMap>(event: K, observer: Observer<TEventMap[K]>): void {
const wrapper: Observer<TEventMap[K]> = (payload) => {
this.off(event, wrapper);
this.onceWrappers.delete(observer as Observer<unknown>);
observer(payload);
};
this.onceWrappers.set(observer as Observer<unknown>, wrapper as Observer<unknown>);
this.on(event, wrapper);
}
// Remove a once listener before it fires
offOnce<K extends keyof TEventMap>(event: K, observer: Observer<TEventMap[K]>): void {
const wrapper = this.onceWrappers.get(observer as Observer<unknown>);
if (wrapper) {
this.off(event, wrapper as Observer<TEventMap[K]>);
this.onceWrappers.delete(observer as Observer<unknown>);
}
}
// Subscribe to all events - useful for logging/debugging
onAny(observer: Observer<{ event: keyof TEventMap; payload: unknown }>): void {
this.wildcardListeners.add(observer);
}
offAny(observer: Observer<{ event: keyof TEventMap; payload: unknown }>): void {
this.wildcardListeners.delete(observer);
}
// Override emit to include wildcard notification and error handling
override emit<K extends keyof TEventMap>(event: K, payload: TEventMap[K]): void {
// Notify wildcard listeners first
this.wildcardListeners.forEach(observer => {
try {
observer({ event, payload });
} catch (error) {
console.error(`Wildcard listener error for ${String(event)}:`, error);
}
});
// Notify specific listeners with error isolation
super.emit(event, payload);
}
// Async emit that waits for all async handlers
async emitAsync<K extends keyof TEventMap>(
event: K,
payload: TEventMap[K]
): Promise<void> {
const observers = this.getObservers(event);
await Promise.all(
Array.from(observers).map(async (observer) => {
try {
await observer(payload);
} catch (error) {
console.error(`Async listener error for ${String(event)}:`, error);
}
})
);
}
protected getObservers<K extends keyof TEventMap>(event: K): Set<Observer<unknown>> {
return (this as unknown as { listeners: Map<K, Set<Observer<unknown>>> })
.listeners.get(event) ?? new Set();
}
}
The once() method uses a wrapper function pattern. We store the wrapper in a WeakMap so that offOnce() can remove it before it fires. This prevents memory leaks when components unmount before events occur.
Error isolation in emit() ensures one failing observer doesn’t prevent others from receiving the event. In production, you’d likely emit an error event or call a centralized error handler rather than just logging.
Type-Safe Events with Generics
The real power of TypeScript generics emerges when you define specific event maps. The compiler becomes your documentation and your safety net.
// Define your application's event contract
interface OrderEvents {
'order:created': { orderId: string; items: Array<{ sku: string; quantity: number }> };
'order:paid': { orderId: string; amount: number; currency: string };
'order:shipped': { orderId: string; trackingNumber: string; carrier: string };
'order:cancelled': { orderId: string; reason: string };
}
// Create a typed emitter
const orderEmitter = new AdvancedEventEmitter<OrderEvents>();
// TypeScript enforces correct payloads
orderEmitter.on('order:created', (payload) => {
// payload is inferred as { orderId: string; items: Array<...> }
console.log(`Order ${payload.orderId} created with ${payload.items.length} items`);
});
// This would be a compile error:
// orderEmitter.emit('order:created', { orderId: '123' });
// Error: Property 'items' is missing
// This would also be a compile error:
// orderEmitter.on('order:createddd', () => {});
// Error: 'order:createddd' is not assignable to keyof OrderEvents
// Correct usage
orderEmitter.emit('order:paid', {
orderId: 'ORD-001',
amount: 99.99,
currency: 'USD'
});
This approach catches bugs that would otherwise surface only in production. When you rename an event or change its payload structure, TypeScript shows you every location that needs updating.
Practical Implementation: State Management Example
Let’s build a minimal reactive store that demonstrates the Observer pattern in action. This pattern underlies libraries like Redux and MobX.
interface StoreEvents<TState> {
change: { prev: TState; next: TState; path?: string };
error: { error: Error; action: string };
}
class Store<TState extends Record<string, unknown>>
extends AdvancedEventEmitter<StoreEvents<TState>> {
private state: TState;
constructor(initialState: TState) {
super();
this.state = { ...initialState };
}
getState(): Readonly<TState> {
return this.state;
}
setState(updater: Partial<TState> | ((prev: TState) => Partial<TState>)): void {
const prev = this.state;
const updates = typeof updater === 'function' ? updater(prev) : updater;
this.state = { ...prev, ...updates };
this.emit('change', { prev, next: this.state });
}
// Subscribe with automatic cleanup - returns unsubscribe function
subscribe(
listener: (state: TState) => void,
options?: { immediate?: boolean }
): () => void {
const handler = (payload: StoreEvents<TState>['change']) => {
listener(payload.next);
};
this.on('change', handler);
if (options?.immediate) {
listener(this.state);
}
// Return cleanup function
return () => this.off('change', handler);
}
// Select specific slice with equality check
select<TSelected>(
selector: (state: TState) => TSelected,
listener: (selected: TSelected) => void,
isEqual: (a: TSelected, b: TSelected) => boolean = Object.is
): () => void {
let currentValue = selector(this.state);
return this.subscribe((state) => {
const nextValue = selector(state);
if (!isEqual(currentValue, nextValue)) {
currentValue = nextValue;
listener(nextValue);
}
});
}
}
// Usage example
interface AppState {
user: { name: string; email: string } | null;
items: string[];
loading: boolean;
}
const store = new Store<AppState>({
user: null,
items: [],
loading: false
});
// Subscribe to all changes
const unsubscribeAll = store.subscribe((state) => {
console.log('State updated:', state);
});
// Subscribe only to user changes
const unsubscribeUser = store.select(
(state) => state.user,
(user) => console.log('User changed:', user)
);
// Update state
store.setState({ loading: true });
store.setState((prev) => ({
items: [...prev.items, 'new item'],
loading: false
}));
// Cleanup when done
unsubscribeAll();
unsubscribeUser();
The subscribe() method returns an unsubscribe function—a pattern you’ll recognize from React’s useEffect. The select() method adds selector support with equality checking, preventing unnecessary notifications when unrelated state changes.
Best Practices and Common Pitfalls
Memory leaks are the Observer pattern’s biggest enemy. Every on() call that isn’t matched by an off() keeps the observer in memory, and often keeps the observer’s entire closure scope alive too.
class Component {
private disposables: Array<() => void> = [];
constructor(private emitter: AdvancedEventEmitter<AppEvents>) {
// Track all subscriptions
this.disposables.push(
this.createSubscription('userLoggedIn', this.handleLogin.bind(this)),
this.createSubscription('dataLoaded', this.handleData.bind(this))
);
}
private createSubscription<K extends keyof AppEvents>(
event: K,
handler: Observer<AppEvents[K]>
): () => void {
this.emitter.on(event, handler);
return () => this.emitter.off(event, handler);
}
private handleLogin(payload: AppEvents['userLoggedIn']): void {
console.log(`Welcome, ${payload.email}`);
}
private handleData(payload: AppEvents['dataLoaded']): void {
console.log(`Loaded ${payload.total} items`);
}
// Call this when component is destroyed
dispose(): void {
this.disposables.forEach(cleanup => cleanup());
this.disposables = [];
}
}
// Modern alternative using AbortController
function subscribeWithAbort<TEventMap extends Record<string, unknown>>(
emitter: AdvancedEventEmitter<TEventMap>,
signal: AbortSignal
) {
return <K extends keyof TEventMap>(
event: K,
handler: Observer<TEventMap[K]>
): void => {
emitter.on(event, handler);
signal.addEventListener('abort', () => {
emitter.off(event, handler);
}, { once: true });
};
}
// Usage with AbortController
const controller = new AbortController();
const subscribe = subscribeWithAbort(orderEmitter, controller.signal);
subscribe('order:created', (payload) => console.log(payload));
subscribe('order:paid', (payload) => console.log(payload));
// Later: cleanup all subscriptions at once
controller.abort();
Avoid circular dependencies where Observer A triggers an event that causes Observer B to trigger an event that causes Observer A to trigger again. Use flags, debouncing, or restructure your event flow.
Choose EventEmitter over pub/sub when components live in the same process and you want type safety. Choose pub/sub (Redis, RabbitMQ) for cross-process or cross-service communication. Choose signals or reactive primitives (like SolidJS signals) when you need fine-grained reactivity with automatic dependency tracking.
The Observer pattern remains foundational because it solves a universal problem: letting parts of your system communicate without creating tight coupling. A well-implemented EventEmitter gives you that communication channel with full type safety.