Skip to content

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 of Event['kind'] has been covered.
  • Adding a new variant to Event without 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.”

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) => state if 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.

// Plain switch — works but loses exhaustiveness
override 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 assertNever at 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.

// 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);
});
  • 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.