Event Dispatcher
PersistentActor.onEvent ist eine reine State-Update-Funktion:
Jede Event-Variante muss behandelt werden, sonst produziert das
Replay falschen State. Die natürliche Form ist ein Switch auf
event.kind — aber TypeScript erzwingt Erschöpfung nicht bei einem
plain Switch innerhalb einer Methode.
eventDispatcher ist ein typisierter Builder, der das tut:
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 des Actors}Drei Dinge fallen auf:
- Jedes
.on(kind, fn)fügt einen Handler für eine Variante hinzu. .build()type-checkt nur, wenn jede Variante vonEvent['kind']abgedeckt ist.- Eine neue Variante zu
Eventhinzuzufügen ohne ein passendes.on(...)verursacht einen Compile-Fehler auf.build().
Der Compile-Fehler nennt die fehlende Art — was den Fix mechanisch
macht: “Ich habe ‘finalized’ hinzugefügt, aber den Handler vergessen;
der Compiler zeigt auf .build() und sagt mir, dass ‘finalized’
fehlt.”
Wie es funktioniert
Abschnitt betitelt „Wie es funktioniert“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<...>;}Der Typ-Parameter Handled verfolgt, welche Arten du hinzugefügt
hast. .build() gibt entweder zurück:
- Eine aufrufbare
(state, event) => state, wenn jede Art abgedeckt ist. - Einen
EventDispatcherIncomplete<...>-Wert sonst — nicht aufrufbar; ihn zu verwenden produziert einen TypeScript-Fehler in der Art von “this expression is not callable; type EventDispatcherIncomplete (mit der unbehandelten Art im Parameter) has no call signatures.”
Das __unhandled-Phantom-Feld trägt die fehlende Art, damit die
Fehlermeldung informativ ist.
Warum es einem plain Switch vorziehen
Abschnitt betitelt „Warum es einem plain Switch vorziehen“// Plain Switch — funktioniert, verliert aber die Erschöpfungsprüfungoverride 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; // ← unbekannte Arten still schlucken}Das Problem:
- Unbekannte Arten fallen durch zu
return state— das Event wird still ignoriert. Nachdem{ kind: 'finalized' }zur Union hinzugefügt wurde, produziert das Replay falschen State, weil die neue Art nie beiträgt. - TypeScript würde das mit
assertNeveram Ende erwischen, aber das ist manuell und leicht zu vergessen. .exhaustive()von ts-pattern funktioniert ebenfalls — siehe Pattern Matching — aber es ist ein Laufzeit-Check innerhalb der Funktion.
eventDispatcher macht den Check nur zur Compile-Zeit — der
Builder-Typ verfolgt, welche Arten unbehandelt bleiben.
Ein praktisches Rezept
Abschnitt betitelt „Ein praktisches Rezept“// Definiere den Dispatcher einmal in der Nähe der Event-Typen: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();
// Verwende ihn im Actor:class Account extends PersistentActor<Cmd, AccountEvent, AccountState> { override onEvent = onAccountEvent; // ... onCommand etc.}Der Dispatcher ist ein reiner Wert — isoliert testbar, ohne Abhängigkeit von der Actor-Instanz:
// Unit-teste den Dispatcher direkt:test('deposit increments balance', () => { const s0 = { balance: 0 }; const s1 = onAccountEvent(s0, { kind: 'deposited', amount: 100 }); expect(s1.balance).toBe(100);});Wann NICHT zu verwenden
Abschnitt betitelt „Wann NICHT zu verwenden“Wie geht’s weiter
Abschnitt betitelt „Wie geht’s weiter“- PersistentActor —
wo der Dispatcher in
onEventeinsteigt. - Pattern Matching — die ts-pattern-Alternative für Laufzeit-Erschöpfung.
- Messages — die
kind-getaggte Diskriminierte-Union-Konvention.
Die eventDispatcher-API-Referenz
deckt den vollständigen Builder ab.