Zum Inhalt springen
Deutsch

Pattern Matching

Innerhalb des onReceive eines Actors empfängst du einen Wert vom Nachrichten-Union-Typ des Actors und dispatched auf seine Form. Diese Codebasis verwendet dafür ts-pattern — spezifisch das match(value).with(...).exhaustive()-Idiom. Drei Vorteile gegenüber handgeschriebenen if/else-Leitern:

  1. Compile-time-Exhaustiveness. Vergiss ein kind, der Compiler scheitert — kein stiller Durchfall.
  2. Typ-Narrowing innerhalb jedes Arms. Keine as-Casts, keine manuellen if (message.kind === ...)-Guards innerhalb von Handlern.
  3. Lesbar für nicht-triviale Unions. Bei 5+ Varianten bleibt die match-Form scannbar; eine if/else if-Leiter tut das nicht.
import { Actor } from 'actor-ts';
import { match } from 'ts-pattern';
type Command =
| { readonly kind: 'increment' }
| { readonly kind: 'decrement' }
| { readonly kind: 'set'; readonly to: number }
| { readonly kind: 'reset' };
class Counter extends Actor<Command> {
private count = 0;
override onReceive(command: Command): void {
match(command)
.with({ kind: 'increment' }, () => this.onIncrement())
.with({ kind: 'decrement' }, () => this.onDecrement())
.with({ kind: 'set' }, (m) => this.onSet(m))
.with({ kind: 'reset' }, () => this.onReset())
.exhaustive();
}
private onIncrement(): void { this.count++; }
private onDecrement(): void { this.count--; }
private onSet(command: Extract<Command, { kind: 'set' }>): void { this.count = command.to; }
private onReset(): void { this.count = 0; }
}

Jeder Arm ist ein dünner Aufruf einer privaten onXxx-Handler-Methode — die Hausregel (siehe unten), nie ein Inline-Rumpf. Das Narrowing überlebt die Übergabe: Der Parameter von onSet ist als Extract<Command, { kind: 'set' }> typisiert, sodass command.to ohne Cast eine number ist.

.exhaustive() am Ende ist der Compile-time-Check: wenn du später { kind: 'double' } zu Command hinzufügst und den passenden .with({ kind: 'double' }, ...)-Arm vergisst, weigert sich TypeScript zu kompilieren und zeigt auf den .exhaustive()-Aufruf. Der Build fängt das Versäumnis ab; du shippst nie eine still verworfene Nachricht.

Beachte das minimale Beispiel oben: Jeder Arm ist ein dünner Aufruf einer privaten onXxx-Methode, kein Inline-Rumpf. Das ist die Hausregel — jeder .with(…) und jedes .otherwise(…) delegiert, auch ein Einzeiler, ohne Ausnahme. Der Matcher bleibt eine scannbare Dispatch-Tabelle; die Logik lebt in benannten Handlern.

  • Benenne den Handler on + PascalCase des kindonIncrement, onData, onCloseAccount; der .otherwise(…)-Fallback heißt onUnhandled.
  • Typisiere den Parameter als die verengte Variante (Extract<Command, { kind: 'set' }>), damit der Handler das Narrowing von ts-pattern behält; lass ihn bei nutzlastfreien Kinds weg.

Das ist eine harte Regel in der AGENTS.md des Repos (Code style).

Die Discriminated-Union-Konvention des Frameworks ist kind: string, klein geschrieben, kebab-case für mehrere Wörter:

type AccountCommand =
| { readonly kind: 'deposit'; readonly amount: number }
| { readonly kind: 'withdraw'; readonly amount: number }
| { readonly kind: 'close-account' }
| { readonly kind: 'get-balance'; readonly replyTo: ActorRef<number> };

Warum kind und nicht type oder tag:

  • type würde mit TypeScripts type-Keyword bei Typ-Narrowing-Lesevorgängen kollidieren (lästig, nicht kaputt).
  • tag ist in Ordnung, aber weniger selbsterklärend als kind.
  • JVM-typisierte Actor-Frameworks verwenden Sealed-Trait-Subklassen; in TS-Land ist kind das häufigste Community-Idiom.

Sich an die Konvention zu halten, zahlt sich an drei Stellen aus: die Exhaustiveness-Prüfung von ts-pattern funktioniert, der Serializer kann über die Wire-Grenze per kind dispatchen, und Editoren können auf der Literal-Union autovervollständigen.

match(command)
.with({ kind: 'deposit', amount: P.number }, (m) => { /* m.amount: number */ })
.with({ kind: 'deposit', amount: 0 }, () => { /* Zero-Amount-Deposit */ })
.otherwise(() => { /* Fallthrough */ });

Patterns können auch auf Feldwerten verfeinern — amount: 0 matcht nur, wenn der Betrag wörtlich null ist, und fällt sonst auf den allgemeineren P.number-Arm durch.

P ist der Pattern-Builder-Namespace von ts-pattern. Nützliche Primitives:

PatternWas es matcht
P.stringJeder String
P.numberJede Zahl
P.array(P.string)Array von Strings
P.union('a', 'b')Eines der Literale
P.when((x) => x > 0)Predicate-Guard
P.anyWildcard

Siehe die ts-pattern-Docs für den vollen Set.

match ist ein Ausdruck — du kannst Werte zurückgeben:

const reply = match(command)
.with({ kind: 'get' }, () => this.onGet()) // onGet(): number
.with({ kind: 'next' }, () => this.onNext()) // onNext(): number
.exhaustive();

Innerhalb des onReceive eines Actors verwendest du es meist für seine Seiteneffekte (tell an eine Reply-to-Ref, Felder mutieren). Aber für typed-Actor-Behavior-Returns ist die Ausdrucksform bequem.

match(command)
.with({ kind: 'increment' }, () => this.onIncrement())
.with({ kind: 'decrement' }, () => this.onDecrement())
.otherwise((m) => this.onUnhandled(m)); // Runtime-Fallback

.otherwise(handler) fängt alles, was nicht gematcht wurde. Verwende es für Actors, die ein Subset eines breiteren Nachrichtentyps behandeln — z.B. ein PersistentActor, der bewusst Commands ignoriert, die er noch nicht behandeln kann.

Default zu .exhaustive(). .otherwise() deaktiviert die Exhaustiveness-Prüfung; greife bewusst dazu, wenn “ignoriere den Rest” die richtige Semantik ist.

ts-pattern ist eine reguläre Runtime-Abhängigkeit des Frameworks — es wird automatisch zusammen mit actor-ts installiert, du musst also nichts zusätzlich hinzufügen.

Jedes Codebeispiel in den Docs nimmt an, dass import { match } from 'ts-pattern' verfügbar ist, weil das die Konvention ist, der die Codebasis folgt: jeder Dispatch auf eine eingehende Nachricht, ein Event oder ein Command läuft über match, und jeder Arm delegiert an einen onXxx-Handler.

  • Nachrichten — die Discriminated-Union-Form, auf der match dispatched.
  • Actor — die onReceive-Signatur, innerhalb derer match läuft.
  • ts-pattern-Dokumentation — die volle Feature-Schnittstelle der Bibliothek.
  • Typed Actors — die typed-API drückt manche Patterns direkter über Behaviors aus.