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:
- Compile-time exhaustiveness. Forget a
kind, the compiler fails — no silent fallthrough. - Type narrowing inside each arm. No
ascasts, no manualif (message.kind === ...)guards inside handlers. - Readable for non-trivial unions. At 5+ variants the
matchform stays scannable; anif/else ifladder doesn’t.
A minimal example
Section titled “A minimal example”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.
Delegate each arm to a private handler
Section titled “Delegate each arm to a private handler”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 PascalCasekind—onIncrement,onData,onCloseAccount; the.otherwise(…)fallback isonUnhandled. - 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 kind convention
Section titled “The kind convention”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:
typewould conflict with TypeScript’stypekeyword in type-narrowing reads (annoying, not broken).tagis fine but less self-explanatory thankind.- JVM-typed actor frameworks use sealed-trait subclasses; in
TS-land,
kindis 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.
Common ts-pattern features
Section titled “Common ts-pattern features”Object-pattern narrowing
Section titled “Object-pattern narrowing”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:
| Pattern | What it matches |
|---|---|
P.string | Any string |
P.number | Any number |
P.array(P.string) | Array of strings |
P.union('a', 'b') | One of the literals |
P.when((x) => x > 0) | Predicate guard |
P.any | Wildcard |
See the ts-pattern docs for the full set.
Returning a value
Section titled “Returning a value”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.
.otherwise vs .exhaustive
Section titled “.otherwise vs .exhaustive”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 as a runtime dependency
Section titled “ts-pattern as a runtime dependency”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.
Where to next
Section titled “Where to next”- Messages — the
discriminated-union shape that
matchdispatches on. - Actor — the
onReceivesignature inside whichmatchruns. - ts-pattern documentation — the library’s full feature surface.
- Typed actors — the typed API expresses some patterns more directly via Behaviors.
