Master TypeScript Event Sourcing by using mapped types and nominal typing to enforce immutable patterns, keeping your domain logic bug-free and predictable.
Last month, I was debugging an event-sourcing implementation where a rogue developer accidentally swapped an OrderCreated event with an OrderUpdated command. The runtime error didn't show up until we hit the reporting service, resulting in about three hours of painful data reconciliation. That's when I realized that standard interfaces just aren't enough for the strict requirements of Event Sourcing.
To solve this, I started leveraging TypeScript's advanced type features to enforce structural integrity at compile-time. If you're building a system where state is derived from a stream of events, your types need to be as immutable as your data.
In a standard CRUD app, your types often just describe the shape of a database row. In Event Sourcing, your types describe a historical record. If you can change an event after it’s been recorded, your entire audit log becomes untrustworthy.
We first tried using simple unions for our events, but that allowed developers to pass commands where events were expected. We needed a way to distinguish between a Command (a request to do something) and an Event (a record of something that happened) even if they shared identical properties.
To differentiate between these concepts, I use nominal typing—often called "branding" or "tagging." As I discussed in my guide on TypeScript Branded Types: Enforcing Domain Integrity at Compile-Time, this prevents accidental assignment of incompatible types.
Here’s how I structure my base types:
TYPESCRIPTtype Brand<K, T> = K & { __brand: T }; type Command<T> = Brand<T, CE9178">'Command'>; type DomainEvent<T> = Brand<T, CE9178">'Event'>; interface CreateOrderPayload { orderId: string; amount: number; } type CreateOrderCommand = Command<CreateOrderPayload>; type OrderCreatedEvent = DomainEvent<CreateOrderPayload>;
By adding that __brand property, TypeScript now sees CreateOrderCommand and OrderCreatedEvent as fundamentally different, even if their inner shapes match. This is a massive win for preventing logic errors during event handlers.
Once you have your branded types, you need to ensure they stay immutable. I use mapped types combined with the readonly modifier to lock down these objects. This is similar to the strategies I use for TypeScript Configuration Patterns: Enforcing Type-Safe Partial Defaults, where strict control over object modification is key.
TYPESCRIPTtype Immutable<T> = { readonly [P in keyof T]: T[P] extends object ? Immutable<T[P]> : T[P]; }; // Now every event is deep-frozen by the type system type ReadonlyEvent<T> = Immutable<DomainEvent<T>>;
Using this Immutable<T> helper, I can guarantee that once an event is created, no part of our application can mutate its properties.
In a real-world scenario, you'll want to map your commands to their resulting events. This is where Mapped Types shine. I define a registry that links every command to the specific event it triggers.
TYPESCRIPTinterface EventMap { CreateOrder: { payload: CreateOrderPayload; event: OrderCreatedEvent }; CancelOrder: { payload: { orderId: string }; event: OrderCancelledEvent }; } type CommandHandler<K extends keyof EventMap> = ( cmd: Command<EventMap[K][CE9178">'payload']> ) => EventMap[K][CE9178">'event'];
This structure makes the code remarkably self-documenting. If I want to see what happens when I trigger a CreateOrder command, I just look at the EventMap. If I try to return the wrong event type from my handler, the compiler yells at me immediately.
Is this overkill? Sometimes. If you’re working on a tiny prototype, the overhead of defining branded types and mapped registries might feel like friction. I’ve definitely had moments where I wanted to just any my way through a quick fix.
However, when you have a system that has been running for six months with thousands of events, this level of strictness is a lifesaver. It forces you to think about the transition from intent (command) to fact (event).
One thing I’m still refining is how to handle versioning. When the CreateOrderPayload changes, you’ll likely need to version your events (e.g., OrderCreatedV2). Currently, I’m exploring whether template literal types—similar to how I handled TypeScript Template Literal Types for Type-Safe Pathing in Configs—can help automate the migration of event schemas. It’s a work in progress, and I’m still not 100% sure if the complexity is worth the benefit there.
Why not just use classes?
Classes can work, but they introduce runtime overhead and instanceof checks. Branded types are erased at runtime, so you get the safety without the extra code weight.
Does this affect performance? Not at all. TypeScript types are strictly for development time. Your production JavaScript stays exactly the same, which is exactly what we want.
How do I handle nested objects?
The Immutable<T> mapped type I shared is recursive. It will traverse through nested objects and apply readonly to every single property, keeping your entire tree immutable.
Ultimately, TypeScript is about making your assumptions explicit. By using these patterns, you’re turning your domain rules into code that the compiler enforces for you. It won't catch every bug, but it will definitely stop the ones that keep you up at night.
TypeScript Value Objects help you eliminate primitive obsession by wrapping raw data in domain-specific types. Learn to prevent bugs with better type safety.
Read moreTypeScript Branded Types help you prevent silent data loss by enforcing strict ID validation at compile-time, moving beyond simple primitive types.