- Book: The TypeScript Type System — From Generics to DSL-Level Types
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
A new code path emits "order.placed" with the payload typed as { orderId: string, total: number }. Existing subscribers expect { order: Order }. Both shapes type-check at the call site because the bus signature is publish(event: string, payload: unknown). Production gets a stream of events that pass schema validation in zero of the three subscribers and crash the projection that builds the order-history view.
The bus did its job. The bus had no idea what events were valid, what payloads went with which event, or whether the subscriber on the other side knew what to do with the message. The bug lives in the type signature of the bus itself, not in the subscriber and not in the publisher.
A typed event bus is a type system the bus carries with it. The discriminator is the routing key. Each event name maps to exactly one payload shape. Subscribers narrow their handler arguments from the same union the publisher pulled from. The compiler refuses unknown names, narrows to the right payload, and complains the moment a new event variant is added without a handler.
Start from a discriminated union of domain events, derive publish and subscribe from it, layer in catch-all subscribers backed by assertNever, talk about wildcards (and when not to bother), then handle the parts that production actually breaks on: async handlers, error isolation, and concurrency.
The Event Type, Where Everything Lives
The first decision is the event type. Get this wrong and nothing the bus does on top is rescuable.
// src/events/types.ts
export type DomainEvent =
| { kind: "user.created"; user: User; at: Date }
| { kind: "user.deleted"; userId: string; at: Date }
| { kind: "order.placed"; order: Order; at: Date }
| { kind: "order.refunded"; orderId: string; cents: number; at: Date }
| { kind: "payment.failed"; orderId: string; reason: string; at: Date };
export type EventKind = DomainEvent["kind"];
export type EventOf<K extends EventKind> = Extract<DomainEvent, { kind: K }>;
Three pieces, each pulling its weight.
DomainEvent is the discriminated union. The discriminator is kind, a string-literal field that the checker uses to narrow. Every variant carries the data its consumers need and a timestamp because every projection eventually needs one. The dotted naming (order.placed, not OrderPlaced or ORDER_PLACED) gives you a free hierarchy when wildcards arrive later, costs nothing to enforce, and reads well in logs.
EventKind is the union of literal strings. "user.created" | "user.deleted" | "order.placed" | "order.refunded" | "payment.failed". The compiler computes this for you; you never type it by hand. Add a new variant to DomainEvent and EventKind updates automatically.
EventOf<K> is the lookup. Given a kind, give me the matching variant. EventOf<"order.placed"> is { kind: "order.placed"; order: Order; at: Date }. This is the type that subscribers and publishers both see. It is built from the same source as the union, so they cannot drift.
This shape works because TypeScript narrows on string-literal discriminants. People come at this from Java or C# and reach for class hierarchies: class OrderPlaced extends DomainEvent. That collapses the moment the event leaves the process. JSON over HTTP loses prototypes. Queues serialise away constructors. structuredClone returns plain objects. A string literal in a kind field survives every boundary because it is just data. That is why discriminant: string is the canonical TypeScript pattern for events and why instanceof is the wrong import.
publish<K> and subscribe<K>
With the union in place, the bus is mechanical.
// src/events/bus.ts
import type { DomainEvent, EventKind, EventOf } from "./types";
type Handler<K extends EventKind> = (e: EventOf<K>) => void | Promise<void>;
export class EventBus {
private handlers: {
[K in EventKind]?: Set<Handler<K>>;
} = {};
publish<K extends EventKind>(kind: K, event: EventOf<K>): void {
const set = this.handlers[kind] as Set<Handler<K>> | undefined;
if (!set) return;
for (const h of set) {
try {
void h(event);
} catch (err) {
// sync error in handler: log, do not throw
console.error(`[bus] ${kind} handler threw`, err);
}
}
}
subscribe<K extends EventKind>(kind: K, handler: Handler<K>): () => void {
const set = (this.handlers[kind] ??= new Set()) as Set<Handler<K>>;
set.add(handler);
return () => set.delete(handler);
}
}
Read the signatures slowly. publish<K extends EventKind>(kind: K, event: EventOf<K>) says: pick any kind from the union, the second argument has to be the variant matching that kind. Pass "order.placed" and the second argument has to be { kind: "order.placed"; order: Order; at: Date }. Pass "order.plced" (typo) and the call fails to compile because "order.plced" is not in EventKind. There is no way to call publish with a mismatched payload. The type relation between the two arguments is what keeps the bus honest.
subscribe<K extends EventKind>(kind: K, handler: Handler<K>) is the mirror. Handler<K> is (e: EventOf<K>) => void | Promise<void>. The handler's argument is narrowed by the kind you subscribed to. Inside a subscriber for "order.placed", e.order is Order, not Order | undefined, not unknown. The narrowing happens at the type signature, not via a runtime check inside the handler. You do not write if (e.kind === "order.placed") inside a handler that already subscribed to that kind.
The internal storage is { [K in EventKind]?: Set<Handler<K>> }. A mapped type over the kind union, each slot holding a set of handlers for that specific kind. The ? makes each slot optional because subscribers register lazily. The Set makes unsubscription O(1) and naturally dedupes accidental double-registration of the same handler reference.
The cast as Set<Handler<K>> inside the methods is unavoidable. TypeScript cannot prove that this.handlers[kind] (indexed by a generic K) holds handlers narrowed to that exact K. You and I can prove it because we wrote the code; the variance machinery cannot. The cast is the price of admission for keeping the public API perfectly typed. Bury it inside the class, never expose it.
What This Buys You at the Call Site
Theory aside, the working developer's experience is what matters.
const bus = new EventBus();
// Publish — types align or the file goes red.
bus.publish("user.created", {
kind: "user.created",
user: { id: "u_1", email: "g@example.com" },
at: new Date(),
});
// bus.publish("user.created", { kind: "user.created", user: { id: "u_1" } });
// ^ Property 'email' is missing
// bus.publish("order.placed", { kind: "user.created", user: ..., at: ... });
// ~~~~~~~~~~~~~~~ kind doesn't match the literal
// Subscribe — handler arg is narrowed automatically.
const off = bus.subscribe("order.placed", (e) => {
// e is { kind: "order.placed"; order: Order; at: Date }
console.log(e.order.id, e.at.toISOString());
});
off(); // unsubscribe
Three classes of bug the compiler now catches:
-
Unknown event names.
bus.publish("oder.placed", ...)does not compile. -
Mismatched payloads.
bus.publish("order.placed", { ... })with the wrong shape does not compile. -
Subscribers that read fields off the wrong variant. Inside a
"order.placed"subscriber,e.userIdis a type error because the variant has nouserId.
This is the floor. A typed bus that does not deliver these three is not pulling its weight.
The Catch-All Subscriber and assertNever
Some subscribers want every event. Audit logs. Outbox writers. Metrics counters. A catch-all subscriber is a function (e: DomainEvent) => void. It takes the full union and the implementation is responsible for branching on the kind.
The naive shape:
bus.subscribeAll((e) => {
audit.log(e.kind, e.at);
});
The type-safe shape forces exhaustiveness inside the handler:
import { assertNever } from "../lib/exhaustive";
bus.subscribeAll((e) => {
switch (e.kind) {
case "user.created":
audit.log("user.created", { userId: e.user.id });
return;
case "user.deleted":
audit.log("user.deleted", { userId: e.userId });
return;
case "order.placed":
audit.log("order.placed", { orderId: e.order.id });
return;
case "order.refunded":
audit.log("order.refunded", { orderId: e.orderId, cents: e.cents });
return;
case "payment.failed":
audit.log("payment.failed", { orderId: e.orderId, reason: e.reason });
return;
default:
return assertNever(e);
}
});
The helper:
// src/lib/exhaustive.ts
export function assertNever(value: never, message?: string): never {
throw new Error(
message ?? `unhandled event: ${JSON.stringify(value)}`,
);
}
assertNever takes never as its parameter. The only value the checker lets you pass is one it has narrowed to never. If every prior case returned, the residual is never and the call type-checks. Add a new variant to DomainEvent without updating this switch and the assertNever(e) call goes red. e still narrows to the new variant, which is not assignable to never. The error points at the line where the assumption broke. Fix it by adding the new case. The seatbelt held.
The subscribeAll method on the bus:
type AllHandler = (e: DomainEvent) => void | Promise<void>;
export class EventBus {
// ...previous fields...
private allHandlers = new Set<AllHandler>();
subscribeAll(handler: AllHandler): () => void {
this.allHandlers.add(handler);
return () => this.allHandlers.delete(handler);
}
publish<K extends EventKind>(kind: K, event: EventOf<K>): void {
const set = this.handlers[kind] as Set<Handler<K>> | undefined;
const targets: Array<() => void | Promise<void>> = [];
if (set) for (const h of set) targets.push(() => h(event));
for (const h of this.allHandlers) targets.push(() => h(event));
for (const run of targets) {
Promise.resolve()
.then(run)
.catch((err) => console.error(`[bus] ${kind}`, err));
}
}
}
The naive shape (try { void run(); } catch (err) { ... }) only catches synchronous throws. A handler that returns a rejected promise slips past the try/catch and lands as an unhandled rejection because void discards the promise. Wrapping in Promise.resolve().then(run).catch(...) normalises both shapes: a sync throw inside run is caught by the chain, and a rejected promise from an async handler ends up in the same .catch. One handler's failure cannot poison another's.
The catch-all and the per-kind handlers fire from the same publish call. Order: per-kind first, catch-all second. This matters when the catch-all is an outbox writer. Side-effects in the per-kind handler should happen before the durable record is written.
Wildcards: subscribe("order.*", ...)
You will eventually want a subscriber for "all order events" without writing a switch over every order kind. Prefix wildcards are a one-template-literal-type extension on top of EventOf<K>:
type EventsOfPrefix<P extends string> =
Extract<DomainEvent, { kind: `${P}.${string}` }>;
type WildcardKey<P extends string> = `${P}.*`;
interface EventBus {
subscribeWildcard<P extends string>(
pattern: WildcardKey<P>,
handler: (e: EventsOfPrefix<P>) => void | Promise<void>,
): () => void;
}
Inside subscribeWildcard("order.*", h), the handler's argument narrows to the union { kind: "order.placed"; order: Order; at: Date } | { kind: "order.refunded"; orderId: string; cents: number; at: Date }. The discriminated union does the rest. Inside the handler you switch (e.kind) and the checker narrows each case to the right payload, with assertNever at the bottom for the same exhaustiveness guarantee.
The runtime side is a second handler set keyed by prefix; publish walks the prefix buckets after the exact-match bucket. Useful for cross-cutting concerns (audit, metrics, tracing spans, an outbox), easy to overuse for everything else. If a wildcard handler is going to special-case three of four variants anyway, subscribe to the three and drop the wildcard.
Mitt (advertised at ~200 bytes gzipped on its README) and Nano Events (~108 bytes brotlied per its README) skip wildcards entirely. That is the right call for a UI library where events live in one component tree. For a domain-event bus inside a backend service, the prefix wildcard pays off.
Async Handlers, Errors, and Concurrency
The synchronous bus above hides the production part. Real handlers do I/O. They write to a database, call an HTTP service, push to a queue. They take milliseconds, sometimes seconds, and they fail.
Three questions a production bus has to answer:
- Does
publishwait for handlers to finish? - If two handlers run for the same event, do they run in parallel or in sequence?
- If one handler throws, what happens to the others?
The defaults that work for most domain-event traffic: publish is fire-and-forget at the call site, handlers run concurrently inside publish, errors in one handler do not affect others, and a separate awaitable variant exists for tests and shutdown.
async publishAndWait<K extends EventKind>(
kind: K,
event: EventOf<K>,
): Promise<void> {
const set = this.handlers[kind] as Set<Handler<K>> | undefined;
const tasks: Promise<void>[] = [];
if (set) {
for (const h of set) {
tasks.push(
Promise.resolve()
.then(() => h(event))
.catch((err) => {
console.error(`[bus] ${kind} handler failed`, err);
}),
);
}
}
The catch-alls follow the same Promise.resolve().then(...).catch(...) shape so a sync throw or a rejected promise from either bucket lands in the same isolated .catch.
for (const h of this.allHandlers) {
tasks.push(
Promise.resolve()
.then(() => h(event))
.catch((err) => {
console.error(`[bus] ${kind} catch-all failed`, err);
}),
);
}
await Promise.all(tasks);
}
Promise.resolve().then(() => h(event)) is the trick that handles the sync-vs-async ambiguity. h returns void | Promise<void>. Wrapping in a resolved promise normalises both into the same chain. Sync throws and rejected promises both end up in the .catch. The .catch per task is the error-isolation guarantee: one handler's failure cannot fail-fast the others, and the outer Promise.all resolves once every task either completed or was caught.
For tests this is the variant you want. await bus.publishAndWait("order.placed", e) finishes only when every handler is done. The fire-and-forget publish is fine for the production hot path where you do not want a slow projection blocking a request, but tests need the deterministic version.
If you need backpressure (a handler slower than the publish rate, an unbounded queue you cannot afford), the bus is the wrong tool. That problem belongs in a real broker (NATS, Kafka, SQS) where the queue is durable and the consumer can checkpoint. An in-process bus is for low-latency notifications inside one service, not for absorbing burst traffic.
Domain Events from Aggregates, Projections from Subscribers
The bus pays for itself when the type discipline reaches the aggregate that emits and the projection that reacts.
The aggregate side:
// src/domain/order.ts
import type { EventOf } from "../events/types";
export class OrderAggregate {
private pending: Array<EventOf<"order.placed" | "order.refunded">> = [];
place(items: LineItem[], customerId: string): void {
const order = buildOrder(items, customerId);
this.pending.push({ kind: "order.placed", order, at: new Date() });
}
refund(orderId: string, cents: number): void {
this.pending.push({ kind: "order.refunded", orderId, cents, at: new Date() });
}
drainEvents(): Array<EventOf<"order.placed" | "order.refunded">> {
const out = this.pending;
this.pending = [];
return out;
}
}
The aggregate's pending array is typed to the events it can emit. You cannot accidentally push a user.created event from inside an order aggregate. The type forbids it. After the transaction commits, the application layer drains the events and publishes them on the bus.
const events = order.drainEvents();
for (const e of events) {
// This is the subtle line. e is the union of two variants.
// bus.publish needs the kind argument narrowed.
switch (e.kind) {
case "order.placed": bus.publish("order.placed", e); break;
case "order.refunded": bus.publish("order.refunded", e); break;
default: assertNever(e);
}
}
The switch looks like ceremony. It is the place where the runtime kind value gets matched to the compile-time literal that publish<K> needs. The assertNever at the bottom is the same guarantee as before. Add a new event kind to the aggregate and this dispatch goes red.
A helper dispatchAll that takes the union and a bus and does this for you is fine to write once and reuse. The version that lives in the bus class:
publishMany<K extends EventKind>(events: EventOf<K>[]): void {
for (const e of events) {
// When the caller passes a union (e.g. EventOf<"order.placed" | "order.refunded">[]),
// K collapses to that union and the cast widens each element to the union type.
// Runtime safety still holds because EventOf<K> guarantees e.kind is in K.
this.publish(e.kind as K, e as EventOf<K>);
}
}
The projection side is the mirror. Subscribers register subscribe("order.placed", project), and the projection is the type-narrowed function that builds the read model.
// src/projections/order-history.ts
import { bus } from "../events";
bus.subscribe("order.placed", async (e) => {
await db.orderHistory.insert({
orderId: e.order.id,
customerId: e.order.customerId,
placedAt: e.at,
total: e.order.total,
});
});
bus.subscribe("order.refunded", async (e) => {
await db.orderHistory.update({
orderId: e.orderId,
refundedCents: e.cents,
refundedAt: e.at,
});
});
Every projection becomes a small file. Each subscriber sees exactly the payload it asked for, the compiler enforces the contract, and adding a new event kind shows up as a missing handler in the projection's catch-all switch (if it has one) or as a deliberate non-subscription (if it does not).
Effect's PubSub gives you a similar shape inside the Effect ecosystem with backpressure, daemonised subscribers, and a Fiber-aware concurrency model. If you are already in Effect, use it. If you are in plain TypeScript without that runtime, the bus above is the equivalent.
The Forward Motion
A typed bus is one of those patterns where the cost is paid up front and the payoff compounds. Once publish<K> and subscribe<K> exist on the same union, every new event is one entry in DomainEvent, one publisher line, however many subscribers want it, and zero meetings about "what fields does that event carry again." The compiler is the documentation.
The next event you add to your service, write the variant first, watch the type errors light up across publishers and projections, and let the checker walk you through the code paths that need to change. The bus stops being a stringly-typed soup and starts being a type system that happens to ship messages.
If this was useful
The discriminated-union work behind a typed event bus is a small slice of the deeper type-system territory The TypeScript Type System covers — generics, mapped and conditional types, infer, template literals, branded types, and the patterns that let library authors expose APIs the compiler can prove correct. If you want to see how to push the same machinery further (typed query builders, route-level type safety, schema-derived validators), that is the book.
The five-book set:
- TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser — entry point: amazon.com/dp/B0GZB7QRW3
- The TypeScript Type System — From Generics to DSL-Level Types — deep dive: amazon.com/dp/B0GZB86QYW
- Kotlin and Java to TypeScript — A Bridge for JVM Developers — bridge for JVM devs: amazon.com/dp/B0GZB2333H
- PHP to TypeScript — A Bridge for Modern PHP 8+ Developers — bridge for PHP devs: amazon.com/dp/B0GZBD5HMF
- TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes — production layer: amazon.com/dp/B0GZB7F471
All five books ship in ebook, paperback, and hardcover.

Top comments (0)