Table of Contents
- Introduction
- What is Event-Driven Programming (EDP)?
- Why Typed Events Matter
- Building a Simple Event System in TypeScript
- Defining Typed Event Interfaces
- Creating an Event Emitter Class
- Subscribing, Emitting, and Unsubscribing
- Example: Building a Notification System
- Advantages of Typed Events
- Best Practices
- Conclusion
Introduction
Event-Driven Programming (EDP) is a fundamental paradigm where the flow of a program is determined by events such as user actions, sensor outputs, or messages from other programs. In large applications, especially, events decouple components and make systems more scalable and responsive.
TypeScript adds an extra layer of safety and clarity to EDP through typed events, ensuring that both event emitters and listeners agree on the event data structures.
In this article, we’ll do a deep dive into Event-Driven Programming using strongly typed events in TypeScript.
What is Event-Driven Programming (EDP)?
In Event-Driven Programming:
- A producer emits an event when something happens (e.g., button click, data received).
- One or more consumers (listeners) react to the event by executing callback functions.
This model allows:
- Loose coupling between components.
- Asynchronous, reactive flows.
- Cleaner separation of concerns.
Familiar examples:
- UI libraries (React, Vue, Angular event handlers).
- Backend systems (Node.js
EventEmitter
). - Messaging systems (RabbitMQ, Kafka).
Why Typed Events Matter
In JavaScript, event handling often relies on loose, dynamic typing.
Problems with dynamic typing in events:
- Event payloads might be missing or have unexpected structures.
- Listeners might assume wrong types, leading to runtime errors.
Solution: TypeScript allows you to strongly type events and event payloads, catching mismatches at compile time.
Typed events ensure:
- Event names are predictable (string literals or enums).
- Payloads follow strict contracts.
- Listeners and emitters stay in sync as the codebase evolves.
Building a Simple Event System in TypeScript
Let’s create a basic, type-safe Event Emitter.
1. Defining Typed Event Interfaces
First, define the list of events and their associated data:
interface AppEvents {
userCreated: { userId: string; username: string };
userDeleted: { userId: string };
errorOccurred: { message: string; code: number };
}
Each key represents an event name, and its value is the type of payload it emits.
2. Creating an Event Emitter Class
Let’s create a generic EventEmitter that uses the AppEvents
type.
type EventHandler<T> = (payload: T) => void;
class TypedEventEmitter<Events extends Record<string, any>> {
private listeners: {
[K in keyof Events]?: EventHandler<Events[K]>[];
} = {};
on<K extends keyof Events>(eventName: K, handler: EventHandler<Events[K]>) {
if (!this.listeners[eventName]) {
this.listeners[eventName] = [];
}
this.listeners[eventName]!.push(handler);
}
off<K extends keyof Events>(eventName: K, handler: EventHandler<Events[K]>) {
if (!this.listeners[eventName]) return;
this.listeners[eventName] = this.listeners[eventName]!.filter(h => h !== handler);
}
emit<K extends keyof Events>(eventName: K, payload: Events[K]) {
if (!this.listeners[eventName]) return;
for (const handler of this.listeners[eventName]!) {
handler(payload);
}
}
}
Now, on
, off
, and emit
are fully type-checked!
3. Subscribing, Emitting, and Unsubscribing
const emitter = new TypedEventEmitter<AppEvents>();
// Subscribe
emitter.on('userCreated', (payload) => {
console.log(`New user created: ${payload.username}`);
});
// Emit
emitter.emit('userCreated', { userId: '123', username: 'JohnDoe' });
// Unsubscribe
const handler = (payload: { userId: string }) => {
console.log(`User deleted: ${payload.userId}`);
};
emitter.on('userDeleted', handler);
// Later...
emitter.off('userDeleted', handler);
Type Safety in Action
If you make a mistake, TypeScript will catch it:
// ❌ Type Error: missing 'userId'
emitter.emit('userDeleted', { id: '123' });
// ❌ Type Error: unexpected event name
emitter.on('nonExistentEvent', () => {});
Example: Building a Notification System
Let’s build a real-world-like notification service.
interface NotificationEvents {
newMessage: { from: string; content: string };
userOnline: { userId: string };
userOffline: { userId: string };
}
const notificationEmitter = new TypedEventEmitter<NotificationEvents>();
// UI component listens for new messages
notificationEmitter.on('newMessage', (payload) => {
console.log(`[Message] ${payload.from}: ${payload.content}`);
});
// System updates online status
notificationEmitter.on('userOnline', ({ userId }) => {
console.log(`User ${userId} is now online.`);
});
// Emit events
notificationEmitter.emit('newMessage', { from: 'Alice', content: 'Hi there!' });
notificationEmitter.emit('userOnline', { userId: 'user123' });
Advantages of Typed Events
- Compile-Time Safety: Mistyped event names or wrong payload structures are caught during development.
- Better Developer Experience: IntelliSense/Autocomplete support for event names and payloads.
- Clearer Contracts: Easily understand what data flows through events.
- Easier Refactoring: Rename event names or change payload types confidently.
- Reduced Bugs: Avoid runtime surprises caused by loose typing.
Best Practices
- Centralize Event Definitions: Use a single interface (like
AppEvents
) to avoid duplication and ensure synchronization. - Use Narrow Payload Types: Avoid using broad types like
any
. - Avoid Too Many Global Events: Group events by bounded contexts (Domain Events, UI Events, etc.).
- Prefer Strong Typing Even in Third-Party Libraries: If a library’s events are not typed, create your own thin wrapper.
- Clean Up Listeners: Always unsubscribe (
off
) listeners to prevent memory leaks.
Conclusion
Typed Event-Driven Programming in TypeScript brings the best of both worlds: the flexibility of event systems and the safety of static typing.
By defining typed event maps and building type-safe emitters, you create robust and scalable systems that are easy to maintain and extend.
As applications grow, typed events become even more valuable for avoiding fragile, error-prone event handling logic.