Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Phantom Types — The Core Idea

A phantom type parameter is a type parameter that appears in the generic signature of a type but is never stored as a value at runtime. The parameter exists exclusively for the type checker — it carries information that the compiler uses to verify constraints, but that information is erased completely during compilation. At runtime, there is no trace of it.

Phantom types are not a TypeScript invention. They appear in Haskell, OCaml, Rust, and other languages with expressive type systems. In Haskell, the classic example is newtype Dollars a = Dollars Int — the a parameter is phantom because Dollars always stores an Int, regardless of a. But Dollars USD and Dollars EUR are distinct types, so the compiler prevents mixing currencies.

In TypeScript, a phantom type parameter looks like this:

interface Tagged<T> {
  readonly value: string;
  readonly _phantom?: T;  // never accessed at runtime
}

The _phantom field is optional and never set. It exists only so the compiler can distinguish Tagged<'email'> from Tagged<'username'> — even though both contain just a string at runtime:

function sendEmail(to: Tagged<'email'>): void { /* ... */ }

const email: Tagged<'email'> = { value: 'user@example.com' };
const username: Tagged<'username'> = { value: 'jdoe' };

sendEmail(email);     // OK
sendEmail(username);  // Compile error: Tagged<'username'> is not assignable to Tagged<'email'>

At runtime, email and username are identical objects: { value: '...' }. The _phantom field is absent. But the compiler treats them as different types and prevents the caller from passing a username where an email is expected.

This is the technique the event system uses. An event definition carries the event name at runtime (for dispatchEvent) and the payload type at compile time (for type checking). The payload type is phantom — it is never stored on the event definition object. But it propagates through the type system to ensure that emitters and listeners agree on the payload shape.

Why Not a Simple Generic?

A natural first instinct is to use a regular generic:

interface TypedEvent<D> {
  name: string;
  defaultPayload: D;
}

This works if D is always something concrete — string, number, { slug: string }. But what about events with no payload? The DOM's Event class carries no data beyond the event name. Events like app-ready and toc-animation-done are pure signals — they communicate "something happened" without carrying any information.

With a regular generic, you would need TypedEvent<void> or TypedEvent<undefined> for void events. But then defaultPayload must be either void (which cannot be stored as a value) or undefined (which wastes a property for no purpose).

Phantom types solve this cleanly: the D parameter exists only at the type level. For void events, D = void. For detail events, D = { slug: string; path: string }. In both cases, the runtime object is identical — just { name: string }. The D only matters when the compiler checks emit() and on() signatures.

Why Not a Union Discriminant?

Another approach is to use a discriminated union:

type AppEvent =
  | { type: 'app-ready' }
  | { type: 'scrollspy-active'; slug: string; path: string }
  | { type: 'sidebar-mask-change'; masked: boolean };

This provides type safety — the compiler narrows the union on type — but it violates the open/closed principle. Every new event requires modifying the AppEvent union. In a 43-machine codebase where different teams (or different development phases) add events, a central union becomes a merge-conflict bottleneck.

The phantom type approach is open for extension: any module can call defineEvent('my-event') to create a new event without touching the event-bus file. The event bus itself does not know about any specific events — it is parameterized by whatever event types the caller provides.

EventDef<N, D> — Three Lines That Change Everything

Here is the complete EventDef interface:

export interface EventDef<N extends string = string, D = void> {
  readonly name: N;
  /** @internal Phantom — never accessed at runtime. */
  readonly _phantom?: D;
}

Three lines. Two type parameters. One runtime field.

  • N extends string — the event name. Constrained to string at minimum, but in practice narrowed to a string literal like 'app-ready' or 'scrollspy-active' via the factory function.

  • D = void — the detail/payload type. Defaults to void (no payload). For events with data, D is the shape of the payload object. This parameter is phantom — the _phantom field is optional and never set.

  • name: N — the only runtime field. This is the string passed to new Event(name) or new CustomEvent(name, { detail }) when the event is dispatched.

The _phantom field exists solely so TypeScript's structural type system can distinguish EventDef<'app-ready', void> from EventDef<'scrollspy-active', { slug: string; path: string }>. Without it, both would be structurally identical — both just { name: string } — and the compiler would consider them interchangeable.

Why readonly?

The name field is readonly because event definitions are immutable. Once created, an EventDef never changes. This is reinforced at the factory level with Object.freeze().

Why _phantom? with ??

The _phantom field is optional (?) so it does not need to be set when creating the event definition. The factory function creates { name } — an object with only the name field. The _phantom field is absent at runtime. TypeScript still uses it for type checking because optional fields participate in structural compatibility checks.

defineEvent() — The Open/Closed Factory

The factory function creates an EventDef from a string name:

export function defineEvent<N extends string, D = void>(name: N): EventDef<N, D> {
  return Object.freeze({ name }) as EventDef<N, D>;
}

The function:

  1. Takes a string name and narrows it to the literal type N via generic inference.
  2. Creates a frozen object { name } — a single field, immutable.
  3. Casts to EventDef<N, D> to attach the phantom type parameter D.

The Object.freeze() ensures that no one can accidentally mutate the event name after creation. The as EventDef<N, D> cast is safe because the phantom field is never accessed — the cast adds type information that the compiler uses but that does not exist at runtime.

Void Events

For events with no payload, the D parameter defaults to void:

export const AppReady = defineEvent('app-ready');
// Type: EventDef<'app-ready', void>

The inferred type is EventDef<'app-ready', void>. When this event is emitted, no detail argument is needed.

Detail Events

For events with a typed payload, the caller provides D explicitly:

export const ScrollspyActive = defineEvent<'scrollspy-active', { slug: string; path: string }>('scrollspy-active');
// Type: EventDef<'scrollspy-active', { slug: string; path: string }>

When this event is emitted, the caller must provide { slug: string; path: string } as the detail argument.

Module-Private Events

Not all events are shared. Some events are private to a single module. The hot-reload-actions module defines its own events inline:

// In src/lib/hot-reload-actions.ts
export const HotReloadContent = defineEvent<'hot-reload:content', { pages?: string[] }>('hot-reload:content');
export const HotReloadToc = defineEvent('hot-reload:toc');

These events are exported (for the adapter to use) but they are not in the shared events.ts file. They live next to the machine that owns them. This is the open/closed property: the event system does not need to know about these events in advance. Any module can define new events without touching the event-bus file.

The Shared Event Registry

Events that are used by multiple machines live in a shared file:

// src/lib/events.ts

import { defineEvent } from './event-bus';

// ── Page lifecycle ──────────────────────────────────────────────────────
export const AppReady = defineEvent('app-ready');
export const AppRouteReady = defineEvent('app-route-ready');

// ── TOC lifecycle ───────────────────────────────────────────────────────
export const TocHeadingsRendered = defineEvent('toc-headings-rendered');
export const TocActiveReady = defineEvent('toc-active-ready');
export const TocAnimationDone = defineEvent('toc-animation-done');

// ── Sidebar ─────────────────────────────────────────────────────────────
export const SidebarMaskChange = defineEvent<'sidebar-mask-change', { masked: boolean }>('sidebar-mask-change');

// ── ScrollSpy ───────────────────────────────────────────────────────────
export const ScrollspyActive = defineEvent<'scrollspy-active', { slug: string; path: string }>('scrollspy-active');

// ── Mermaid ─────────────────────────────────────────────────────────────
export const MermaidConfigReady = defineEvent('mermaid-config-ready');

This file is a registry, not a requirement. New events can be defined anywhere. The shared file exists for discoverability — when multiple machines reference the same event, having it in one place reduces the risk of duplicate definitions.

DetailOf — Extracting the Payload Type

The bus needs to know what arguments emit() and on() should accept. Given an EventDef<N, D>, the compiler needs to extract D — the phantom payload type. This is done with a conditional type:

export type DetailOf<E extends EventDef> =
  E extends EventDef<string, infer D> ? D : never;

DetailOf takes an EventDef and uses infer D to extract the second type parameter:

DetailOf<typeof AppReady>          // void
DetailOf<typeof ScrollspyActive>   // { slug: string; path: string }
DetailOf<typeof SidebarMaskChange> // { masked: boolean }
DetailOf<typeof HotReloadContent>  // { pages?: string[] }
DetailOf<typeof HotReloadToc>      // void

This extracted type is used in the emit() and on() signatures to enforce payload correctness.

The Variadic Trick

The key design challenge is that void events should be called with one argument (bus.emit(AppReady)) while detail events should be called with two arguments (bus.emit(ScrollspyActive, { slug, path })). TypeScript does not support overloads on generic parameters directly, but it does support conditional tuple types in rest parameters:

emit<E extends TEmits>(
  ...args: DetailOf<E> extends void ? [event: E] : [event: E, detail: DetailOf<E>]
): void;

This is a single function signature that behaves like two overloads:

  • When DetailOf<E> is void (e.g., AppReady), the args tuple is [event: E] — one argument.
  • When DetailOf<E> is not void (e.g., ScrollspyActive), the args tuple is [event: E, detail: DetailOf<E>] — two arguments.

The compiler enforces this at the call site:

bus.emit(AppReady);                              // OK — one arg for void event
bus.emit(AppReady, {});                           // Error — void event takes no detail
bus.emit(ScrollspyActive, { slug: 'x', path: 'y' }); // OK — correct detail shape
bus.emit(ScrollspyActive);                        // Error — detail events require detail
bus.emit(ScrollspyActive, { slug: 'x' });         // Error — missing 'path' property

Five call sites, five correct compile-time decisions. No runtime checks needed.

EventBus<TEmits, TListens> — The Port

The EventBus interface is the port through which machines interact with the event system. It is parameterized by two unions of EventDef types: what the consumer may emit and what it may listen to.

export interface EventBus<
  TEmits   extends EventDef<string, any> = never,
  TListens extends EventDef<string, any> = never,
> {
  emit<E extends TEmits>(
    ...args: DetailOf<E> extends void ? [event: E] : [event: E, detail: DetailOf<E>]
  ): void;

  on<L extends TListens>(
    event: L,
    handler: DetailOf<L> extends void ? () => void : (detail: DetailOf<L>) => void,
  ): Subscription;
}

Two Independent Type Parameters

The bus has two type parameters — TEmits and TListens — because emission and listening are independent permissions. A machine that emits scrollspy-active should not automatically be able to listen to scrollspy-active. And a machine that listens to app-ready should not be able to emit it.

This is the principle of least privilege applied to events. Each machine receives exactly the permissions it needs, and no more.

Default never

Both parameters default to never. A bus with no type arguments — EventBus<> — can neither emit nor listen to any event. This is a safe default: you must explicitly declare what events a bus can use.

The emit() Method

emit<E extends TEmits>(
  ...args: DetailOf<E> extends void ? [event: E] : [event: E, detail: DetailOf<E>]
): void;
  • E extends TEmits — the event must be in the TEmits union. If the bus is EventBus<typeof AppReady, ...>, then E can only be typeof AppReady. Attempting to emit any other event is a compile error.
  • The rest parameter uses the variadic trick described above to enforce void vs detail events.
  • Return type is void — emit is fire-and-forget.

The on() Method

on<L extends TListens>(
  event: L,
  handler: DetailOf<L> extends void ? () => void : (detail: DetailOf<L>) => void,
): Subscription;
  • L extends TListens — the event must be in the TListens union.
  • The handler type adapts to the event's payload: void events get () => void, detail events get (detail: D) => void.
  • Returns a Subscription — a function that, when called, removes the listener. Idempotent (safe to call multiple times).

The Subscription Type

export type Subscription = () => void;

A Subscription is a cleanup function. Call it to unsubscribe. Call it again — no effect.

The implementation tracks a removed flag:

function on(event: EventDef, handler: (detail?: unknown) => void): Subscription {
  let removed = false;
  const listener = (ev: Event): void => {
    const d = (ev as CustomEvent).detail;
    if (d !== undefined && d !== null) {
      handler(d);
    } else {
      handler();
    }
  };
  target.addEventListener(event.name, listener);
  return (): void => {
    if (removed) return;
    removed = true;
    target.removeEventListener(event.name, listener);
  };
}

The removed flag ensures removeEventListener is called at most once, even if the subscription function is invoked multiple times. This prevents the double-unsubscribe bug that plagues manual addEventListener/removeEventListener pairing.

Concrete Bus Types

Each machine declares its own bus type — a specific instantiation of EventBus with exactly the events it needs.

ScrollSpyBus

The scroll spy machine emits one event and listens to two:

// In src/lib/scroll-spy-machine.ts
import type { EventBus } from './event-bus';
import { ScrollspyActive, TocHeadingsRendered, TocAnimationDone } from './events';

export type ScrollSpyBus = EventBus<
  typeof ScrollspyActive,                              // can emit
  typeof TocHeadingsRendered | typeof TocAnimationDone  // can listen to
>;

With this bus type:

  • bus.emit(ScrollspyActive, { slug, path }) compiles.
  • bus.emit(AppReady) does not compile — AppReady is not in TEmits.
  • bus.on(TocHeadingsRendered, () => { ... }) compiles.
  • bus.on(SidebarMaskChange, (d) => { ... }) does not compile — SidebarMaskChange is not in TListens.

TourBus

The tour state machine listens to one event and emits none:

// In src/lib/tour-state.ts
export type TourBus = EventBus<never, typeof AppReady>;
  • bus.on(AppReady, () => { ... }) compiles.
  • bus.emit(anything) does not compile — TEmits is never.

This captures the semantics precisely: the tour reacts to the app being ready, but it does not signal anything to other machines through events.

PageLoadBus

The page load machine emits two events and listens to none:

// In src/lib/page-load-state.ts
export type PageLoadBus = EventBus<typeof AppReady | typeof TocHeadingsRendered, never>;
  • bus.emit(AppReady) compiles.
  • bus.emit(TocHeadingsRendered) compiles.
  • bus.on(anything) does not compile — TListens is never.

HotReloadEmitBus

The hot-reload module emits two module-private events:

// In src/lib/hot-reload-actions.ts
export type HotReloadEmitBus = EventBus<typeof HotReloadContent | typeof HotReloadToc, never>;

Note that HotReloadContent and HotReloadToc are defined in the same file — they are not imported from the shared events.ts. The bus type is still fully typed.

TerminalDotsBus

The terminal dots machine listens to one event and emits none:

// In src/lib/terminal-dots-state.ts
export type TerminalDotsBus = EventBus<never, typeof SidebarMaskChange>;

The terminal dots adapter listens for sidebar-mask-change and calls syncSidebarMasked(masked) on the machine. The machine itself is pure — it does not know about events. The bus type documents the adapter's event participation.

TocBreadcrumbBus

The breadcrumb machine emits two lifecycle events:

// In src/lib/toc-breadcrumb-state.ts
export type TocBreadcrumbBus = EventBus<
  typeof TocActiveReady | typeof TocAnimationDone,
  never
>;

When the typewriter animation reaches a certain point, the adapter emits toc-active-ready. When the animation completes, the adapter emits toc-animation-done. The scroll spy listens to toc-animation-done to know when it is safe to re-index headings (during animation, positions are unstable).

EventTargetLike — The Narrow Surface

The bus adapter wraps an EventTarget — but not the full DOM EventTarget. It depends on a narrow interface:

export interface EventTargetLike {
  addEventListener(type: string, listener: (ev: Event) => void): void;
  removeEventListener(type: string, listener: (ev: Event) => void): void;
  dispatchEvent(ev: Event): boolean;
}

This narrow surface means the bus can wrap window, document, or a test fake — a plain object that implements three methods:

// In unit tests:
function createFakeTarget(): EventTargetLike {
  const listeners = new Map<string, Set<(ev: Event) => void>>();
  return {
    addEventListener(type, listener) {
      if (!listeners.has(type)) listeners.set(type, new Set());
      listeners.get(type)!.add(listener);
    },
    removeEventListener(type, listener) {
      listeners.get(type)?.delete(listener);
    },
    dispatchEvent(ev) {
      for (const fn of listeners.get(ev.type) ?? []) fn(ev);
      return true;
    },
  };
}

No JSDOM. No browser. No heavyweight test infrastructure. Just a plain object with three methods. This is why the bus is testable in isolation — the adapter pattern means the bus never depends on the real DOM.

createEventBus() — The Adapter

The factory function creates a concrete EventBus backed by an EventTargetLike:

export function createEventBus<
  TEmits   extends EventDef<string, any> = never,
  TListens extends EventDef<string, any> = never,
>(target: EventTargetLike): EventBus<TEmits, TListens> {

  function emit(event: EventDef, detail?: unknown): void {
    if (detail !== undefined) {
      target.dispatchEvent(new CustomEvent(event.name, { detail }));
    } else {
      target.dispatchEvent(new Event(event.name));
    }
  }

  function on(event: EventDef, handler: (detail?: unknown) => void): Subscription {
    let removed = false;
    const listener = (ev: Event): void => {
      const d = (ev as CustomEvent).detail;
      if (d !== undefined && d !== null) {
        handler(d);
      } else {
        handler();
      }
    };
    target.addEventListener(event.name, listener);
    return (): void => {
      if (removed) return;
      removed = true;
      target.removeEventListener(event.name, listener);
    };
  }

  return { emit, on } as EventBus<TEmits, TListens>;
}

Runtime Behavior

At runtime, the bus:

  1. emit(event, detail?) — creates a CustomEvent (if detail provided) or Event (if not) and dispatches it on the target.
  2. on(event, handler) — adds a listener that extracts the detail from the event and calls the handler.

The type parameters TEmits and TListens are erased at runtime. The emit and on functions accept any EventDef at runtime — the constraint is purely at compile time.

This is the phantom type in action: zero runtime cost, full compile-time safety.

The as Cast

The return statement uses as EventBus<TEmits, TListens>. This cast is safe because:

  1. The runtime implementation accepts any event — it reads event.name and dispatches.
  2. The type system constrains which events the consumer can pass — via TEmits and TListens.
  3. The constraint is on the consumer, not the implementation. The implementation is permissive; the types are restrictive.

This pattern — permissive implementation, restrictive types — is how phantom types always work. The runtime does not enforce the constraint; the compiler does.

Wiring in the Adapter

In app-static.ts, the application entry point creates a bus and passes it to the machines:

// In src/app-static.ts
const staticBus = createEventBus<
  typeof TocHeadingsRendered | typeof AppReady | typeof AppRouteReady,
  typeof ScrollspyActive | typeof TocActiveReady | typeof TocAnimationDone
>(window as unknown as EventTargetLike);

This is the composition root — the single place where the bus is created with the full union of events the entry point needs. Individual machines receive narrower bus types via their dependency signatures.

Four Compile Errors

Here are four concrete mistakes that the typed event system catches at compile time.

Error 1: Typo in Event Name

// Without typed events:
window.dispatchEvent(new Event('app-redy')); // No error. Phantom event.

// With typed events:
bus.emit(AppRedy); // TS2304: Cannot find name 'AppRedy'.

Because events are constants, not strings, a typo produces a "name not found" error. The developer gets red squigglies in their IDE before they even save the file.

Error 2: Wrong Payload Shape

// Without typed events:
window.dispatchEvent(new CustomEvent('scrollspy-active', {
  detail: { slug: 'install', href: '/docs/install' }
  //                          ^^^^ should be 'path', not 'href'
}));
// No error. Listener gets { slug: 'install', href: '/docs/install' }, accesses .path → undefined.

// With typed events:
bus.emit(ScrollspyActive, { slug: 'install', href: '/docs/install' });
// TS2353: Object literal may only specify known properties,
// and 'href' does not exist in type '{ slug: string; path: string }'.

The phantom type D = { slug: string; path: string } propagates through DetailOf<E> into the emit() signature. The compiler checks the payload shape and rejects href.

Error 3: Unauthorized Emit

// The tour has TourBus = EventBus<never, typeof AppReady>
// It can listen to AppReady but cannot emit anything.

tourBus.emit(AppReady);
// TS2345: Argument of type 'EventDef<"app-ready", void>' is not assignable
// to parameter of type 'never'.

The TEmits = never constraint means the bus has no authorized emissions. Any emit() call fails because nothing extends never.

Error 4: Missing Detail Argument

// ScrollspyActive requires { slug: string; path: string }
bus.emit(ScrollspyActive);
// TS2554: Expected 2 arguments, but got 1.

The variadic tuple [event: E, detail: DetailOf<E>] requires two arguments when DetailOf<E> is not void. Forgetting the detail is a compile error.

What the Type System Does Not Catch

The typed event system prevents typos, payload mismatches, unauthorized events, and missing arguments. But it does not prevent:

  • Semantic drift — the event fires at a different lifecycle point, but the name and payload are correct.
  • Missing listeners — an event is emitted but no one listens. The type system ensures the emit is valid; it does not ensure anyone cares.
  • Missing emitters — a listener waits for an event that no machine emits.

These gaps are filled by the topology scanner (Part VII), which verifies that every emitted event has at least one listener and every listened event has at least one emitter. The type system and the scanner are complementary — the type system catches structural errors at compile time, the scanner catches topological errors at build time.

The EventBusFsm Companion

Even the event bus itself has a @FiniteStateMachine decorator:

@FiniteStateMachine({
  states:      ['ready'] as const,
  events:      ['emit', 'on', 'unsubscribe'] as const,
  description: 'Typed event bus — mediates all cross-FSM custom events with compile-time safety.',
  transitions: [
    { from: 'ready', to: 'ready', on: 'emit' },
    { from: 'ready', to: 'ready', on: 'on' },
    { from: 'ready', to: 'ready', on: 'unsubscribe' },
  ] as const,
  scope: 'singleton',
})
export class EventBusFsm {}

This makes the bus visible in the state graph extracted by the build pipeline (Part IX). The bus is a single-state machine — always ready — with three events that loop back to the same state. It is a degenerate machine (no meaningful state transitions), but including it in the graph makes the topology complete.

The Complete Module

Here is src/lib/event-bus.ts in its entirety — 187 lines of source that provide the type-safe event foundation for the entire SPA:

/**
 * Typed EventBus — replaces implicit window.dispatchEvent/addEventListener.
 *
 * Open/Closed: each event is an `EventDef` object declared wherever it
 * makes sense (typically next to the machine that owns it). Adding a new
 * event never modifies this file.
 *
 * The bus is parameterized by two unions of EventDef types: what it can
 * emit and what it can listen to. A machine that declares
 * `emits: ['scrollspy-active']` receives a bus typed accordingly —
 * calling `bus.emit(WrongEvent)` is a compile error, not a phantom.
 *
 * Design:
 *   - EventDef<N, D>    — a typed event (name at runtime, detail at compile time)
 *   - defineEvent()     — factory for EventDef (open for extension)
 *   - EventBus<E, L>   — the port (injected into machines)
 *   - createEventBus() — the adapter (wraps a real EventTarget)
 *   - Subscription      — returned by `on()`, call to unsubscribe
 */

import { FiniteStateMachine } from './finite-state-machine';

// ────────────────────────────────────────────────────────────────────────────
// EventDef — the open type for events
// ────────────────────────────────────────────────────────────────────────────

export interface EventDef<N extends string = string, D = void> {
  readonly name: N;
  /** @internal Phantom — never accessed at runtime. */
  readonly _phantom?: D;
}

export type DetailOf<E extends EventDef> =
  E extends EventDef<string, infer D> ? D : never;

export function defineEvent<N extends string, D = void>(name: N): EventDef<N, D> {
  return Object.freeze({ name }) as EventDef<N, D>;
}

// ────────────────────────────────────────────────────────────────────────────
// Subscription handle
// ────────────────────────────────────────────────────────────────────────────

export type Subscription = () => void;

// ────────────────────────────────────────────────────────────────────────────
// EventBus port — the interface machines depend on
// ────────────────────────────────────────────────────────────────────────────

export interface EventBus<
  TEmits   extends EventDef<string, any> = never,
  TListens extends EventDef<string, any> = never,
> {
  emit<E extends TEmits>(
    ...args: DetailOf<E> extends void ? [event: E] : [event: E, detail: DetailOf<E>]
  ): void;

  on<L extends TListens>(
    event: L,
    handler: DetailOf<L> extends void ? () => void : (detail: DetailOf<L>) => void,
  ): Subscription;
}

// ────────────────────────────────────────────────────────────────────────────
// EventTarget port — narrow surface for testability
// ────────────────────────────────────────────────────────────────────────────

export interface EventTargetLike {
  addEventListener(type: string, listener: (ev: Event) => void): void;
  removeEventListener(type: string, listener: (ev: Event) => void): void;
  dispatchEvent(ev: Event): boolean;
}

// ────────────────────────────────────────────────────────────────────────────
// EventBus factory — the adapter
// ────────────────────────────────────────────────────────────────────────────

export function createEventBus<
  TEmits   extends EventDef<string, any> = never,
  TListens extends EventDef<string, any> = never,
>(target: EventTargetLike): EventBus<TEmits, TListens> {

  function emit(event: EventDef, detail?: unknown): void {
    if (detail !== undefined) {
      target.dispatchEvent(new CustomEvent(event.name, { detail }));
    } else {
      target.dispatchEvent(new Event(event.name));
    }
  }

  function on(event: EventDef, handler: (detail?: unknown) => void): Subscription {
    let removed = false;
    const listener = (ev: Event): void => {
      const d = (ev as CustomEvent).detail;
      if (d !== undefined && d !== null) {
        handler(d);
      } else {
        handler();
      }
    };
    target.addEventListener(event.name, listener);
    return (): void => {
      if (removed) return;
      removed = true;
      target.removeEventListener(event.name, listener);
    };
  }

  return { emit, on } as EventBus<TEmits, TListens>;
}

// ────────────────────────────────────────────────────────────────────────────
// FSM metadata — topology scanner visibility
// ────────────────────────────────────────────────────────────────────────────

@FiniteStateMachine({
  states:      ['ready'] as const,
  events:      ['emit', 'on', 'unsubscribe'] as const,
  description: 'Typed event bus — mediates all cross-FSM custom events with compile-time safety.',
  transitions: [
    { from: 'ready', to: 'ready', on: 'emit' },
    { from: 'ready', to: 'ready', on: 'on' },
    { from: 'ready', to: 'ready', on: 'unsubscribe' },
  ] as const,
  scope: 'singleton',
})
export class EventBusFsm {}
Diagram
The type-level flow — defineEvent creates an EventDef, DetailOf extracts the phantom parameter, and the bus methods use it for compile-time safety.
Diagram
Event catalog — seven void signals and two detail carriers. All nine are compile-time typed.
Diagram
Each machine gets a bus scoped to exactly its needs — the type system enforces least privilege.

Why Not Branded Types?

TypeScript's branded types (type Email = string & { __brand: 'email' }) are an alternative to phantom types. The difference is that branded types add a runtime field (or pretend to) while phantom types add only a type-level field.

For event definitions, phantom types are the better fit because:

  1. The event definition object should be as small as possible — just { name: string }.
  2. The payload type (D) has no meaningful runtime value to store — it describes the shape of another object (the event detail), not a property of the event definition itself.
  3. Branded types would require defineEvent to return { name: string; __brand: D }, which pollutes the object with a runtime field that is never read.

Why Not a Map?

An alternative design stores events in a Map<string, EventDef>:

const events = new Map<string, EventDef>();
events.set('app-ready', defineEvent('app-ready'));

This centralizes all events in one data structure, which makes it easy to enumerate them. But it has two problems:

  1. Type safety requires the map to be typed as Map<string, EventDef<string, unknown>> — the specific phantom types are lost.
  2. The open/closed principle is violated — every new event must be added to the map, which is a modification.

The current design — individual const declarations — preserves the full phantom type on each event and allows new events to be defined anywhere.

Why Not TypeScript's GlobalEventHandlersEventMap?

TypeScript allows extending the WindowEventMap interface to add custom events:

declare global {
  interface WindowEventMap {
    'app-ready': Event;
    'scrollspy-active': CustomEvent<{ slug: string; path: string }>;
  }
}

This makes window.addEventListener('scrollspy-active', handler) type-safe without a custom bus. The handler receives the correct event type.

The problem is scope. WindowEventMap is global — it applies to every addEventListener call in the entire application. There is no way to restrict which modules can emit which events. Any module can dispatch 'scrollspy-active' even if it has no business doing so. The EventBus<TEmits, TListens> pattern provides per-machine scoping — a machine receives only the events it is authorized to use.

Additionally, WindowEventMap requires declare global augmentation, which is fragile in multi-package monorepos and can cause conflicts with third-party type definitions.

Summary

The typed event system replaces stringly-typed dispatchEvent/addEventListener with a three-component architecture:

  1. EventDef<N, D> — an immutable event definition with a runtime name and a phantom payload type.
  2. EventBus<TEmits, TListens> — a port interface that constrains which events a consumer can emit and listen to.
  3. createEventBus(target) — an adapter that wraps any EventTarget with the typed bus interface.

The system catches four categories of compile-time errors: event name typos, payload shape mismatches, unauthorized emissions, and missing detail arguments. It adds zero runtime cost — the phantom types are erased during compilation, and the adapter delegates directly to dispatchEvent/addEventListener.

What the type system cannot catch — missing listeners, missing emitters, and semantic drift — is addressed by the topology scanner in Part VII.

Continue to Part III: @FiniteStateMachine — The Decorator That Becomes a Database →

⬇ Download