Skip to content

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 (msg.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 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();
}
}

Inside the set arm, m is automatically typed as { kind: 'set'; to: number } — the discriminator narrows the type without a cast.

.exhaustive() at the end is the compile-time check: if you add { kind: 'double' } to Cmd 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.

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

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

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.
  • Akka uses 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(cmd)
.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(cmd)
.with({ kind: 'get' }, () => this.count)
.with({ kind: 'inc' }, () => this.count + 1)
.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(cmd)
.with({ kind: 'inc' }, () => { ... })
.with({ kind: 'dec' }, () => { ... })
.otherwise(() => this.log.warn('unknown cmd')); // runtime fallback

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

For two- or three-variant unions, a plain if-chain reads cleaner:

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 narrows inside each branch — the else arm sees { kind: 'get'; replyTo: ... } automatically. You lose the .exhaustive() check, but at 2-3 variants the cost is low.

The rule of thumb: use match from 4+ variants, especially when arms need destructuring ((m) => m.amount).

ts-pattern is a peer dependency of the framework — you install it yourself:

Terminal window
bun add ts-pattern
# or: npm install ts-pattern

The framework doesn’t require you to use it; you can write onReceive as a plain switch. But every code example in the docs assumes import { match } from 'ts-pattern' is available, because that’s the convention the codebase follows.

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