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 (msg.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 Cmd =
| { readonly kind: 'inc' }
| { readonly kind: 'dec' }
| { readonly kind: 'set'; readonly to: number }
| { readonly kind: 'reset' };
class Counter extends Actor<Cmd> {
private count = 0;
override onReceive(cmd: Cmd): void {
match(cmd)
.with({ kind: 'inc' }, () => { this.count++; })
.with({ kind: 'dec' }, () => { this.count--; })
.with({ kind: 'set' }, (m) => { this.count = m.to; })
.with({ kind: 'reset' }, () => { this.count = 0; })
.exhaustive();
}
}

Innerhalb des set-Arms ist m automatisch als { kind: 'set'; to: number } typisiert — der Diskriminator verengt den Typ ohne Cast.

.exhaustive() am Ende ist der Compile-time-Check: wenn du später { kind: 'double' } zu Cmd 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.

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

type AccountCmd =
| { 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(cmd)
.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(cmd)
.with({ kind: 'get' }, () => this.count)
.with({ kind: 'inc' }, () => this.count + 1)
.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(cmd)
.with({ kind: 'inc' }, () => { ... })
.with({ kind: 'dec' }, () => { ... })
.otherwise(() => this.log.warn('unknown cmd')); // Runtime-Fallback

.otherwise(fn) 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.

Für Unions mit zwei oder drei Varianten liest sich eine einfache if-Kette sauberer:

override onReceive(cmd: Cmd): void {
if (cmd.kind === 'inc') this.count++;
else if (cmd.kind === 'dec') this.count--;
else cmd.replyTo.tell(this.count);
}

TypeScript verengt innerhalb jedes Branchs — der else-Arm sieht automatisch { kind: 'get'; replyTo: ... }. Du verlierst den .exhaustive()-Check, aber bei 2-3 Varianten sind die Kosten niedrig.

Die Faustregel: verwende match ab 4+ Varianten, besonders wenn Arms Destructuring brauchen ((m) => m.amount).

ts-pattern ist eine Peer-Abhängigkeit des Frameworks — du installierst es selbst:

Terminal-Fenster
bun add ts-pattern
# oder: npm install ts-pattern

Das Framework verlangt nicht, dass du es verwendest; du kannst onReceive als einfaches Switch schreiben. Aber 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.

  • 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.