Event dispatcher
PersistentActor.onEvent is a pure state-update function:
every event variant must be handled, or replay produces
incorrect state. The natural shape is a switch on event.kind
— but TypeScript doesn’t enforce exhaustiveness on a plain switch
inside a method.
eventDispatcher is a typed builder that does:
import { eventDispatcher } from 'actor-ts';
type State = { count: number };type Event = | { kind: 'incremented'; by: number } | { kind: 'decremented'; by: number } | { kind: 'reset' };
const onEvent = eventDispatcher<State, Event>() .on('incremented', (s, e) => ({ count: s.count + e.by })) .on('decremented', (s, e) => ({ count: s.count - e.by })) .on('reset', () => ({ count: 0 })) .build();
class Counter extends PersistentActor<Cmd, Event, State> { override onEvent = onEvent; // ... rest of the actor}Three things to notice:
- Each
.on(kind, fn)adds a handler for one variant. .build()only type-checks when every variant ofEvent['kind']has been covered.- Adding a new variant to
Eventwithout a matching.on(...)causes a compile error on.build().
The compile error names the missing kind — making the fix
mechanical: “I added ‘finalized’ but forgot a handler; the
compiler points at .build() and tells me ‘finalized’ is
missing.”
How it works
Section titled “How it works”interface EventDispatcherBuilder<S, E, Handled extends string> { on<K extends Exclude<E['kind'], Handled>>( kind: K, fn: (state: S, event: Extract<E, { kind: K }>) => S, ): EventDispatcherBuilder<S, E, Handled | K>;
build(): Handled extends E['kind'] ? (state: S, event: E) => S : EventDispatcherIncomplete<...>;}The Handled type parameter tracks which kinds you’ve added.
.build() returns either:
- A callable
(state, event) => stateif every kind is covered. - An
EventDispatcherIncomplete<...>value otherwise — not callable; using it produces a TypeScript error along the lines of “this expression is not callable; type EventDispatcherIncomplete (with the unhandled kind in the parameter) has no call signatures.”
The __unhandled phantom field carries the missing kind so the
error message is informative.
Why use it over a plain switch
Section titled “Why use it over a plain switch”// Plain switch — works but loses exhaustivenessoverride onEvent(state: State, event: Event): State { switch (event.kind) { case 'incremented': return { count: state.count + event.by }; case 'decremented': return { count: state.count - event.by }; case 'reset': return { count: 0 }; } return state; // ← silently swallow unknown kinds}The issue:
- Unknown kinds fall through to
return state— the event is silently ignored. After adding{ kind: 'finalized' }to the union, replay produces wrong state because the new kind never contributes. - TypeScript would catch this if you
assertNeverat the end, but it’s manual and easy to forget. - ts-pattern’s
.exhaustive()works too — see Pattern matching — but it’s a runtime check inside the function.
eventDispatcher makes the check compile-time only — the
builder type-tracks which kinds remain unhandled.
A practical recipe
Section titled “A practical recipe”// Define the dispatcher once, near the event types:const onAccountEvent = eventDispatcher<AccountState, AccountEvent>() .on('deposited', (s, e) => ({ balance: s.balance + e.amount })) .on('withdrawn', (s, e) => ({ balance: s.balance - e.amount })) .on('frozen', (s) => ({ ...s, frozen: true })) .on('unfrozen', (s) => ({ ...s, frozen: false })) .build();
// Use it in the actor:class Account extends PersistentActor<Cmd, AccountEvent, AccountState> { override onEvent = onAccountEvent; // ... onCommand etc.}The dispatcher is a pure value — testable in isolation, no dependency on the actor instance:
// Unit test the dispatcher directly:test('deposit increments balance', () => { const s0 = { balance: 0 }; const s1 = onAccountEvent(s0, { kind: 'deposited', amount: 100 }); expect(s1.balance).toBe(100);});When NOT to use it
Section titled “When NOT to use it”Where to next
Section titled “Where to next”- PersistentActor —
where the dispatcher slots into
onEvent. - Pattern matching — the ts-pattern alternative for runtime exhaustiveness.
- Messages — the
kind-tagged discriminated-union convention.
The eventDispatcher
API reference covers the full builder.