I’ve been thinking about building more resilient systems lately. Why? Because in my last project, a tightly coupled monolith caused cascading failures that took hours to resolve. That frustration sparked my journey into event-driven systems. Today I’ll share how to build a type-safe event architecture using TypeScript, EventEmitter3, and Redis Pub/Sub. Stick with me – this approach could prevent those late-night fire drills for you too.
First, let’s establish our foundation. We need a project structure that scales. Here’s what works well:
npm install eventemitter3 redis ioredis zod
npm install -D typescript @types/node
Our TypeScript config ensures strict type safety:
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"moduleResolution": "node"
}
}
Now, the core question: how do we make events both type-safe and extensible? The answer lies in our base event structure:
// events/base/Event.ts
import { v4 as uuidv4 } from 'uuid';
export abstract class Event {
public readonly id: string;
public readonly timestamp: Date = new Date();
constructor(
public readonly type: string,
public readonly payload: unknown
) {
this.id = uuidv4();
}
}
This abstract class gives us consistent event metadata. But how do we enforce payload shapes? That’s where Zod schemas come in:
// events/user/UserEvents.ts
import { z } from 'zod';
export const UserCreatedSchema = z.object({
userId: z.string().uuid(),
email: z.string().email(),
name: z.string()
});
export class UserCreatedEvent extends Event {
constructor(payload: z.infer<typeof UserCreatedSchema>) {
super('UserCreated', UserCreatedSchema.parse(payload));
}
}
Now, what about handling these events? We need a bus that understands our types. Here’s a local implementation using EventEmitter3:
// events/local/LocalEventBus.ts
import EventEmitter from 'eventemitter3';
import { Event } from '../base/Event';
type EventHandler<T extends Event> = (event: T) => void;
export class LocalEventBus {
private emitter = new EventEmitter();
publish<T extends Event>(event: T): void {
this.emitter.emit(event.type, event);
}
subscribe<T extends Event>(
eventType: string,
handler: EventHandler<T>
): void {
this.emitter.on(eventType, handler);
}
}
But what happens when we scale beyond a single process? That’s where Redis Pub/Sub enters the picture. Notice how we maintain type safety even across network boundaries:
// events/distributed/RedisEventBus.ts
import { Redis } from 'ioredis';
import { Event } from '../base/Event';
export class RedisEventBus {
private publisher: Redis;
private subscriber: Redis;
constructor() {
this.publisher = new Redis();
this.subscriber = new Redis();
}
async publish(event: Event): Promise<void> {
await this.publisher.publish(
event.type,
JSON.stringify(event)
);
}
subscribe<T extends Event>(
eventType: string,
handler: (event: T) => void
): void {
this.subscriber.subscribe(eventType);
this.subscriber.on('message', (channel, message) => {
if (channel === eventType) {
handler(JSON.parse(message) as T);
}
});
}
}
Now, let’s address error handling – a critical but often overlooked aspect. How do we ensure failed events don’t disappear into the void? We implement a dead-letter queue pattern:
// events/handlers/ErrorHandler.ts
import { EventBus } from '../base/EventBus';
export class ErrorHandler {
constructor(
private mainBus: EventBus,
private dlqBus: EventBus
) {}
async handleEvent<T extends Event>(
event: T,
handler: (e: T) => Promise<void>
): Promise<void> {
try {
await handler(event);
} catch (error) {
this.dlqBus.publish({
...event,
error: error.message
});
}
}
}
For production environments, monitoring is non-negotiable. Here’s how we track event flow:
// utils/metrics.ts
import { Event } from '../events/base/Event';
export class EventMetrics {
static trackPublished(event: Event): void {
console.log(`[Published] ${event.type} @ ${event.timestamp}`);
}
static trackProcessed(event: Event, handler: string): void {
console.log(`[Processed] ${handler} for ${event.id}`);
}
static trackFailed(event: Event, error: Error): void {
console.error(`[Failed] ${event.type}: ${error.message}`);
}
}
A common pitfall? Underestimating event ordering needs. If you need strict ordering, consider Redis Streams instead of Pub/Sub. But for most cases, the simpler Pub/Sub works beautifully.
What about testing our events? We use Zod’s built-in validation:
// tests/userEvents.test.ts
import { UserCreatedEvent, UserCreatedSchema } from '../events/user/UserEvents';
test('rejects invalid user data', () => {
const invalidPayload = { email: 'not-an-email' };
expect(() => UserCreatedSchema.parse(invalidPayload)).toThrow();
});
As we wrap up, remember this: type safety isn’t just about preventing bugs. It’s about creating systems that communicate clearly with future developers. When your events carry their own documentation through types, onboarding new team members becomes effortless.
If this approach resonates with you, give it a try in your next project. Have questions about specific implementation details? Share them in the comments – I’d love to hear what challenges you’re facing with event-driven systems. Found this useful? Pass it along to others who might benefit!