Typing Event Bus Payloads with TypeScript #
An event bus is an untyped API between teams by default — this page gives it a discriminated-union contract that tsc enforces, and the runtime validation that catches the publisher compiled against last quarter’s version.
Both halves are necessary. Types catch mistakes inside one build; they cannot see across a federation boundary, where a remote built three weeks ago is still publishing the old payload shape. It extends event bus patterns for decoupled apps.
Prerequisites #
- TypeScript 5.4+ in the host and every remote that publishes or subscribes.
- Zod
^3.23.0for the runtime schemas, or an equivalent that infers types. - The event contract published as a package so every side compiles against it.
- The bus itself shared as a federation singleton, per implementing a global event bus with RxJS.
Step 1 — Model the events as a discriminated union #
One union over all events, discriminated by type, gives TypeScript everything it needs to narrow a payload from the event name alone.
// @acme/events/src/contract.ts
export type AppEvent =
| { type: 'cart:item-added'; v: 1; sku: string; quantity: number }
| { type: 'cart:cleared'; v: 1 }
| { type: 'auth:signed-in'; v: 1; userId: string; scopes: readonly string[] }
| { type: 'auth:signed-out'; v: 1 }
| { type: 'search:performed'; v: 1; query: string; resultCount: number };
export type EventType = AppEvent['type'];
// The payload for one event name, with the discriminant removed.
export type PayloadOf<T extends EventType> = Extract<AppEvent, { type: T }>;
The v field on every member is what makes the contract evolvable across independently-deployed remotes. Without it, a payload change is indistinguishable from a malformed payload at runtime.
Keep the union in one file that nothing else imports from. It is the contract, and a file that also contains helpers acquires dependencies that make it awkward to publish.
Step 2 — Type the bus so publish and subscribe narrow correctly #
The bus’s signature is where the type safety is actually delivered.
// @acme/events/src/bus.ts
import { Subject, filter } from 'rxjs';
import type { AppEvent, EventType, PayloadOf } from './contract';
const subject = new Subject<AppEvent>();
export function publish<T extends EventType>(event: PayloadOf<T>): void {
subject.next(event);
}
export function on<T extends EventType>(type: T) {
// The predicate narrows the stream to exactly this event's payload type.
return subject.pipe(
filter((e): e is PayloadOf<T> => e.type === type),
);
}
// A consumer gets full narrowing with no annotations of its own.
on('cart:item-added').subscribe((e) => {
console.log(e.sku, e.quantity); // both typed
// console.log(e.query); // compile error: not on this payload
});
The type predicate in the filter is what makes the narrowing work. Without it the stream stays AppEvent and every subscriber has to narrow by hand, which defeats the purpose.
Step 3 — Add a runtime schema per event #
Types vanish at compile time. A remote built against version 1 will happily publish a version 1 payload into a graph that has moved to version 2, and nothing in the type system can see it.
// @acme/events/src/schemas.ts
import { z } from 'zod';
export const schemas = {
'cart:item-added': z.object({
type: z.literal('cart:item-added'), v: z.literal(1),
sku: z.string().min(1), quantity: z.number().int().positive(),
}),
'auth:signed-in': z.object({
type: z.literal('auth:signed-in'), v: z.literal(1),
userId: z.string().uuid(), scopes: z.array(z.string()).readonly(),
}),
} as const;
// The compile-time contract and the runtime schemas cannot drift:
// this fails to typecheck if an event is missing a schema.
type _Exhaustive = Record<EventType, unknown> extends typeof schemas ? true : never;
The exhaustiveness check is the part worth copying. Adding an event to the union without adding a schema then fails the contract package’s own build rather than being discovered when the unvalidated event arrives in production.
Step 4 — Validate at the boundary, once #
Validate at publish and drop invalid events there, rather than asking every subscriber to defend itself.
export function publish<T extends EventType>(event: PayloadOf<T>): void {
const schema = schemas[event.type];
const parsed = schema?.safeParse(event);
if (!parsed?.success) {
// A publisher on an older contract, or a genuine bug. Report; never throw.
reportEvent('event-bus-invalid-payload', {
type: event.type,
issues: parsed?.error.issues.map((i) => i.path.join('.')) ?? ['no schema'],
});
return;
}
subject.next(parsed.data);
}
Never throwing is deliberate. A publisher is usually in a click handler or an effect, and an exception there breaks the publishing remote because a subscriber’s contract moved. Dropping and reporting keeps the failure contained to the event that could not be delivered.
Step 5 — Version an event without breaking older publishers #
Because remotes deploy independently, both versions of an event will be in flight simultaneously for as long as the slowest consumer takes to upgrade.
// Both versions are in the union during the migration window.
export type AppEvent =
| { type: 'cart:item-added'; v: 1; sku: string; quantity: number }
| { type: 'cart:item-added'; v: 2; sku: string; quantity: number; source: 'search' | 'browse' };
// Subscribers normalise once, at the edge, rather than branching everywhere.
export function onCartItemAdded() {
return on('cart:item-added').pipe(
map((e) => (e.v === 2 ? e : { ...e, v: 2 as const, source: 'browse' as const })),
);
}
The upgrade order is fixed: subscribers first, publishers second, removal of version 1 last. A subscriber that understands both versions can be deployed at any time; a publisher that emits version 2 before subscribers understand it drops events silently.
Remove version 1 only when telemetry shows nothing has published it for a full deploy cycle — the same evidence-based removal used for backward-compatible remote API contracts.
Step 6 — Publish the contract so every remote compiles against it #
The contract has to reach every remote’s build, which means a package rather than a federated module — types are needed at compile time, not at runtime.
{
"name": "@acme/events",
"version": "2.1.0",
"types": "./dist/index.d.ts",
"exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } },
"peerDependencies": { "rxjs": "^7.8.0", "zod": "^3.23.0" }
}
Keeping rxjs and zod as peer dependencies matters: the bus must be one instance across the whole page, and a nested copy of RxJS would give each remote its own Subject.
Assert in CI that every remote is on a compatible contract major. A remote two majors behind is publishing events nothing validates against, and it will be found by a dropped-event report rather than by a build.
Step 7 — Make dropped events visible #
The failure mode of a validated bus is silence, which is better than a crash and worse than a signal.
// Report both halves: what was rejected, and what nobody is listening to.
const subscribed = new Set<EventType>();
export function on<T extends EventType>(type: T) {
subscribed.add(type);
return subject.pipe(filter((e): e is PayloadOf<T> => e.type === type));
}
// In development, warn about an event published into the void.
if (process.env.NODE_ENV !== 'production') {
subject.subscribe((e) => {
if (!subscribed.has(e.type)) {
console.warn(`[bus] "${e.type}" published with no subscriber on this page`);
}
});
}
The unsubscribed warning catches a common federated mistake: a publisher whose intended consumer is a lazily-loaded remote that has not mounted yet. That is the late-subscriber problem, and it is solved by replay rather than by types — but knowing it is happening is the first step.
Verification #
- Narrowing works: subscribe to one event and confirm accessing a field from a different event is a compile error.
- Exhaustiveness holds: add an event to the union without a schema and confirm the contract package fails to build.
- Invalid payloads are dropped: publish a malformed event from the console and confirm it is reported and not delivered.
- Publishing does not throw: confirm the publishing remote continues working after emitting an invalid payload.
- One bus instance: confirm the share scope lists a single version of the events package and of RxJS.
- Versions coexist: publish version 1 and version 2 of an event and confirm a normalising subscriber handles both.
Troubleshooting #
Symptom: subscribers receive AppEvent instead of the narrowed payload.
Diagnosis: the filter uses a plain boolean predicate rather than a type predicate. Fix: annotate it as (e): e is PayloadOf<T>.
Symptom: events published by one remote never arrive.
Diagnosis: two bus instances, because the events package is not a federation singleton or RxJS is nested. Fix: share both as singletons and keep them as peer dependencies.
Symptom: valid events are rejected after a contract upgrade.
Diagnosis: a publisher on the old contract emits v: 1 while the schema now only accepts v: 2. Fix: keep both versions in the union and the schemas until telemetry shows version 1 is unused.
Symptom: types compile but runtime validation rejects everything.
Diagnosis: the schema and the type have drifted — usually a field added to one and not the other. Fix: add the exhaustiveness check from step 3 so the drift fails the build.
Naming events so the contract stays readable #
A contract of forty events with inconsistent names is one nobody reads before adding the forty-first. Two conventions do most of the work of keeping it navigable.
Name events after what happened, in the past tense, prefixed by the owning domain: cart:item-added, not cart:add-item. A past-tense name describes a fact, which is what an event is, and it discourages the pattern where an event is really a command aimed at one particular subscriber. If a name reads like an instruction, the publisher probably wants a callback rather than a broadcast.
Prefix with the domain that owns the event rather than the remote that publishes it. Remotes get renamed, split and merged; domains do not. An event named checkout:started survives the checkout capability moving from one remote to another, while cart-remote:started becomes a lie the first time the boundary shifts — the same reasoning that makes capability-shaped boundaries outlast page-shaped ones in defining application boundaries.