Skip to content

[Feature] Replace PersistentActor event-type dispatch with match() #239

@pathosDev

Description

@pathosDev

Size / Priority

  • Size: S (~40 lines)
  • Category: C.1 Pattern-Matching.
  • Risk: low.

Affected files

  • src/persistence/PersistentActor.ts — event-type dispatch in applyEvent / onEvent callbacks.

Background

PersistentActor dispatches replayed events to per-type handlers. User-defined event unions look like:

type OrderEvent =
  | { kind: 'OrderCreated'; orderId: string; total: number }
  | { kind: 'ItemAdded'; item: string }
  | { kind: 'OrderPaid'; paymentId: string }
  | { kind: 'OrderShipped'; trackingNumber: string };

Replay handler often uses an if-chain:

override onEvent(state: State, event: Event): State {
  if (event.kind === 'OrderCreated') return { ...state, orderId: event.orderId, total: event.total };
  if (event.kind === 'ItemAdded') return { ...state, items: [...state.items, event.item] };
  // ...
  throw new Error(`unknown event: ${(event as { kind: string }).kind}`);
}

The throw is runtime — adding a new event variant silently fails to be replayed (state drift).

Target code

The framework's own PersistentActor base class doesn't dispatch on user-event types (that's the user's job in onEvent). However, the framework can ship a match-based helper that makes the user-side dispatch ergonomic:

// src/persistence/EventDispatcher.ts (new)

import { match, P } from 'ts-pattern';

/**
 * Helper for defining `onEvent` as an exhaustive event-kind dispatch.
 *
 *   override onEvent = eventDispatcher<State, Event>()
 *     .on('OrderCreated', (state, e) => ({ ...state, orderId: e.orderId }))
 *     .on('ItemAdded',    (state, e) => ({ ...state, items: [...state.items, e.item] }))
 *     // adding a case here is required by the type system if the union grows
 *     .build();
 */
export function eventDispatcher<S, E extends { kind: string }>(): EventDispatcherBuilder<S, E, never>;

interface EventDispatcherBuilder<S, E extends { kind: string }, Handled extends E['kind']> {
  on<K extends Exclude<E['kind'], Handled>>(
    kind: K,
    fn: (state: S, event: Extract<E, { kind: K }>) => S,
  ): EventDispatcherBuilder<S, E, Handled | K>;

  // Only callable when Handled === E['kind'] — i.e., all variants covered.
  build(): [Handled] extends [E['kind']]
    ? (state: S, event: E) => S
    : { error: 'unhandled event kinds'; missing: Exclude<E['kind'], Handled> };
}

Internally, build() materialises a match(event).with({ kind: k1 }, ...).with({ kind: k2 }, ...).exhaustive().

The user-facing benefit: type system refuses to compile until every event variant is handled.

Internal framework usage

Inside PersistentActor, replay also dispatches on framework event types (snapshot vs regular event). Convert that internal dispatch to match exhaustively.

Integration / risk

  • Pure additive — users can keep their existing onEvent if they like.
  • Encouraged by docs as the preferred pattern.

Test plan

  1. Regression — existing PersistentActor tests pass.
  2. Builder TS test — incomplete builder doesn't expose build's callable shape; TS type-test verifies.
  3. Recovery correctness — recovery via the new helper produces identical state to the existing if-chain pattern.

Acceptance criteria

  • eventDispatcher helper exported from src/persistence/.
  • Documentation: "Exhaustive event dispatch with eventDispatcher".
  • Existing internal dispatch in PersistentActor uses match().exhaustive().
  • Test suite + type-test for the helper.
  • CHANGELOG entry under "Persistence: typed event dispatcher helper" (this one IS user-visible, unlike most C.* items).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestpriority: lowNice-to-have / niche / demand-driven

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions