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.

Why a typed event bus still needs runtime validation TypeScript checks one build at one moment; a federated graph runs several builds compiled at different times against different contract versions. Types alone Correct within one build Cannot see across a federation boundary An older publisher still emits v1 Subscriber reads undefined, no error Types plus runtime schemas Compile-time narrowing for authors Runtime validation at the publish boundary Mismatched payloads dropped and reported Both contract versions coexist deliberately
The gap types cannot cover is exactly the gap federation creates — code compiled weeks apart running in one page.

Prerequisites #

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 #

How the event name narrows the payload type A discriminated union plus a type-predicate filter means a subscriber names an event and receives its exact payload with no annotations. Narrowing from the event name alone Discriminated union one type per event Typed publish payload inferred from name Type-predicate filter stream narrows Subscriber fields typed, no casts
The type predicate in stage three is the load-bearing detail — a plain boolean filter leaves the stream unnarrowed.

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 #

Evolving an event across independently-deployed remotes Both payload versions are accepted throughout the migration, and subscribers upgrade before publishers so no event is ever dropped. Subscribers first, publishers second, removal last v2 added both in the union schemas accept both Subscribers upgrade normalise v1 to v2 publishers untouched Publishers upgrade emit v2 v1 still accepted v1 removed telemetry shows zero use contract major bump Removal is gated on telemetry showing no v1 publisher for a full deploy cycle.
Reverse stages two and three and every v2 event is silently discarded until subscribers catch up.

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 #

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.