Type-safe event handling with TypeScript
- 27 Nov 2025 |
- 02 Mins read
At Pangea, we built an event-driven architecture for our FX trading platform. TypeScript's type system enabled us to create type-safe event handlers that catch errors at compile time. Here's how we structured it.
Event Type Definitions
// Base event structure
interface BaseEvent {
eventId: string;
timestamp: Date;
version: string;
}
// Specific event types
interface PaymentCreatedEvent extends BaseEvent {
type: 'payment.created';
payload: {
paymentId: string;
amount: Decimal;
currency: CurrencyCode;
userId: string;
};
}
interface PaymentFailedEvent extends BaseEvent {
type: 'payment.failed';
payload: {
paymentId: string;
reason: string;
errorCode: string;
};
}
interface FXRateUpdatedEvent extends BaseEvent {
type: 'fx.rate.updated';
payload: {
pair: CurrencyPair;
rate: Decimal;
source: 'market' | 'manual';
};
}
// Union type for all events
type PaymentEvent = PaymentCreatedEvent | PaymentFailedEvent;
type FXEvent = FXRateUpdatedEvent;
type SystemEvent = PaymentEvent | FXEvent;
Type-Safe Event Handlers
type EventHandler<T extends SystemEvent> = (event: T) => Promise<void>;
class EventDispatcher {
private handlers: Map<string, EventHandler<any>[]> = new Map();
on<T extends SystemEvent>(
eventType: T['type'],
handler: EventHandler<T>
): void {
if (!this.handlers.has(eventType)) {
this.handlers.set(eventType, []);
}
this.handlers.get(eventType)!.push(handler);
}
async emit<T extends SystemEvent>(event: T): Promise<void> {
const handlers = this.handlers.get(event.type) || [];
await Promise.all(handlers.map(handler => handler(event)));
}
}
// Usage - fully type-safe
const dispatcher = new EventDispatcher();
dispatcher.on('payment.created', async (event) => {
// TypeScript knows event.payload has paymentId, amount, currency, userId
console.log(`Payment ${event.payload.paymentId} created`);
});
dispatcher.on('payment.failed', async (event) => {
// TypeScript knows event.payload has paymentId, reason, errorCode
console.error(`Payment ${event.payload.paymentId} failed: ${event.payload.reason}`);
});
Event Schema Validation
import { z } from 'zod';
const PaymentCreatedSchema = z.object({
type: z.literal('payment.created'),
eventId: z.string().uuid(),
timestamp: z.date(),
version: z.string(),
payload: z.object({
paymentId: z.string().uuid(),
amount: z.string().regex(/^\d+\.\d{2}$/),
currency: z.enum(['USD', 'EUR', 'GBP']),
userId: z.string().uuid(),
}),
});
type ValidatedPaymentCreatedEvent = z.infer<typeof PaymentCreatedSchema>;
function validateEvent(event: unknown): ValidatedPaymentCreatedEvent {
return PaymentCreatedSchema.parse(event);
}
Pattern Matching with Exhaustiveness Checking
function processEvent(event: SystemEvent): void {
switch (event.type) {
case 'payment.created':
handlePaymentCreated(event); // TypeScript narrows type
break;
case 'payment.failed':
handlePaymentFailed(event); // TypeScript narrows type
break;
case 'fx.rate.updated':
handleRateUpdate(event); // TypeScript narrows type
break;
default:
// Exhaustiveness check - TypeScript error if we miss a case
const _exhaustive: never = event;
throw new Error(`Unknown event type: ${_exhaustive}`);
}
}
"Type-safe event handling prevents entire classes of bugs in distributed systems."
Advanced Patterns
Event Versioning:
type EventV1 = { version: '1'; data: OldFormat };
type EventV2 = { version: '2'; data: NewFormat };
type VersionedEvent = EventV1 | EventV2;
function handleVersionedEvent(event: VersionedEvent): void {
if (event.version === '1') {
// Handle v1 format
} else {
// Handle v2 format
}
}
Event Transformation:
type EventTransformer<T extends SystemEvent, U extends SystemEvent> = (
event: T
) => U;
function transformPaymentEvent(
event: PaymentCreatedEvent
): PaymentCreatedEvent {
return {
...event,
payload: {
...event.payload,
amount: event.payload.amount.mul(100), // Convert to cents
},
};
}