跳转到内容
简体中文

Pattern matching

此内容尚不支持你的语言。

Inside an actor’s onReceive, you receive a value of the actor’s message-union type and dispatch on its shape. This codebase uses ts-pattern for that — specifically the match(value).with(...).exhaustive() idiom. Three benefits over hand-rolled if/else ladders:

  1. Compile-time exhaustiveness. Forget a kind, the compiler fails — no silent fallthrough.
  2. Type narrowing inside each arm. No as casts, no manual if (message.kind === ...) guards inside handlers.
  3. Readable for non-trivial unions. At 5+ variants the match form stays scannable; an if/else if ladder doesn’t.
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; }
}

Every arm is a thin call into a private onXxx handler — the house rule (see below), never an inline body. The narrowing survives the hand-off: onSet’s parameter is typed Extract<Command, { kind: 'set' }>, so command.to is a number with no cast.

.exhaustive() at the end is the compile-time check: if you add { kind: 'double' } to Command later and forget the matching .with({ kind: 'double' }, ...) arm, TypeScript refuses to compile, pointing at the .exhaustive() call. The build catches the omission; you never ship a silently-dropped message.

Notice the minimal example above: every arm is a thin call into a private onXxx method, not an inline body. That’s the house rule — each .with(…) and any .otherwise(…) delegates, even a one-liner, no exceptions. The matcher stays a scannable dispatch table; the logic lives in named handlers.

  • Name the handler on + the PascalCase kindonIncrement, onData, onCloseAccount; the .otherwise(…) fallback is onUnhandled.
  • Type the parameter as the narrowed variant (Extract<Command, { kind: 'set' }>) so the handler keeps ts-pattern’s narrowing; omit it for payload-free kinds.

It’s a hard rule in the repo’s AGENTS.md (Code style).

The framework’s discriminated-union convention is kind: string, lowercase, kebab-case for multi-word:

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> };

Why kind and not type or tag:

  • type would conflict with TypeScript’s type keyword in type-narrowing reads (annoying, not broken).
  • tag is fine but less self-explanatory than kind.
  • JVM-typed actor frameworks use sealed-trait subclasses; in TS-land, kind is the most common community idiom.

Sticking to the convention pays off in three places: ts-pattern’s exhaustiveness check works, the serializer can dispatch by kind across the wire, and editors can auto-complete on the literal union.

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

Patterns can refine on field values too — amount: 0 matches only when the amount is literally zero, falling through to the more general P.number arm if not.

P is ts-pattern’s pattern-builder namespace. Useful primitives:

PatternWhat it matches
P.stringAny string
P.numberAny number
P.array(P.string)Array of strings
P.union('a', 'b')One of the literals
P.when((x) => x > 0)Predicate guard
P.anyWildcard

See the ts-pattern docs for the full set.

match is an expression — you can return values:

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

Inside an actor’s onReceive, you mostly use it for its side-effects (tell to a reply-to ref, mutate fields). But for typed-actor Behavior returns, the expression form is convenient.

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

.otherwise(handler) catches anything not matched. Use it for actors that handle a subset of a wider message type — e.g. a PersistentActor that intentionally ignores commands it can’t yet handle.

Default to .exhaustive(). .otherwise() disables the exhaustiveness check; reach for it deliberately when “ignore the rest” is the right semantic.

ts-pattern is a regular runtime dependency of the framework — it’s installed automatically alongside actor-ts, so there’s nothing extra to add.

Every code example in the docs assumes import { match } from 'ts-pattern' is available, because that’s the convention the codebase follows: every dispatch on an incoming message, event or command goes through match, and every arm delegates to an onXxx handler.

  • Messages — the discriminated-union shape that match dispatches on.
  • Actor — the onReceive signature inside which match runs.
  • ts-pattern documentation — the library’s full feature surface.
  • Typed actors — the typed API expresses some patterns more directly via Behaviors.