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
- Regression — existing PersistentActor tests pass.
- Builder TS test — incomplete builder doesn't expose
build's callable shape; TS type-test verifies.
- Recovery correctness — recovery via the new helper produces identical state to the existing if-chain pattern.
Acceptance criteria
Size / Priority
Affected files
src/persistence/PersistentActor.ts— event-type dispatch inapplyEvent/onEventcallbacks.Background
PersistentActordispatches replayed events to per-type handlers. User-defined event unions look like:Replay handler often uses an if-chain:
The throw is runtime — adding a new event variant silently fails to be replayed (state drift).
Target code
The framework's own
PersistentActorbase class doesn't dispatch on user-event types (that's the user's job inonEvent). However, the framework can ship amatch-based helper that makes the user-side dispatch ergonomic:Internally,
build()materialises amatch(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 tomatchexhaustively.Integration / risk
onEventif they like.Test plan
build's callable shape; TS type-test verifies.Acceptance criteria
eventDispatcherhelper exported fromsrc/persistence/.match().exhaustive().