Messages
A message is any value sent to an actor. The type system enforces
which messages a given actor accepts (via the Actor<TMessage> type
parameter), and the runtime delivers messages one at a time to the
actor’s onReceive.
This page covers the conventions actor-ts code follows for shaping messages — what they look like, how to dispatch on them, what NOT to put in them, and the patterns that come up most often.
The discriminated-union convention
Section titled “The discriminated-union convention”The single most-important pattern in this codebase: messages are
discriminated unions tagged by a kind field.
type CounterCommand = | { readonly kind: 'increment' } | { readonly kind: 'decrement' } | { readonly kind: 'set'; readonly to: number } | { readonly kind: 'get'; readonly replyTo: ActorRef<number> };This shape buys three things:
- Compile-time exhaustiveness.
match(command).with(...).exhaustive()(via ts-pattern) refuses to compile if you add a newkindand forget to handle it. The actor-ts codebase uses this pattern everywhere — see Pattern matching. - Type narrowing inside handlers. Inside the
.with({ kind: 'set' }, ...)arm,m.tois typed asnumberautomatically — no casts, noas never. - Wire-format stability. The wire carries no type identity of its
own — once a message that crossed a process or cluster boundary has
been parsed,
kindis what the receiving actor’smatch(...)dispatches on. That is precisely why the discriminant has to travel inside the payload. See Serialization.
The convention is kind: string, lowercase, kebab-case for multi-word.
Capital-K Kind works too but isn’t what the framework uses.
A complete example
Section titled “A complete example”import { Actor, ActorSystem, type ActorRef } from 'actor-ts';import { match } from 'ts-pattern';
type IncrementCommand = { readonly kind: 'increment' };type DecrementCommand = { readonly kind: 'decrement' };type GetCommand = { readonly kind: 'get'; readonly replyTo: ActorRef<number> };type CounterCommand = IncrementCommand | DecrementCommand | GetCommand;
class Counter extends Actor<CounterCommand> { private count = 0;
override onReceive(command: CounterCommand): void { match(command) .with({ kind: 'increment' }, () => this.onIncrement()) .with({ kind: 'decrement' }, () => this.onDecrement()) .with({ kind: 'get' }, (m) => this.onGet(m)) .exhaustive(); }
private onIncrement(): void { this.count++; } private onDecrement(): void { this.count--; } private onGet(m: GetCommand): void { m.replyTo.tell(this.count); }}
const system = ActorSystem.create('counters');const counter = system.spawnAnonymous(Counter);
counter.tell({ kind: 'increment' });counter.tell({ kind: 'increment' });counter.tell({ kind: 'increment' });// later, ask for the count via a reply-to ref — see Ask pattern.If you add a kind: 'reset' variant to CounterCommand later, the
.exhaustive() call fails to compile until you add a .with({ kind: 'reset' }, ...)
arm. No runtime crash, no silently-dropped message — the compiler
catches it.
Immutability rules
Section titled “Immutability rules”Messages should be immutable once enqueued. Two reasons:
- The same message can be processed twice in some scenarios — restart-after-failure replays buffered messages; reliable-delivery re-sends unacknowledged ones. Mutation between deliveries leads to “phantom” state changes.
- Cross-process sends serialize the message at send time and
deserialize at receive time. The receiver gets a copy; mutating
the sender-side object after
tellhas no effect on the copy, but that asymmetry creates confusing bugs (the local case sees mutation, the remote case doesn’t).
Practical rules of thumb:
- Use
readonlyproperties (readonly kind,readonly replyTo, …). The TypeScript compiler then catches accidental writes. - Prefer plain objects (
{ kind: 'x', n: 1 }) over classes — they serialize trivially, no method-loss across process boundaries. - For arrays / nested objects inside a message, treat them as deeply immutable. If you need to “update,” return a new message; don’t mutate the incoming one.
Request/response: the reply-to ref
Section titled “Request/response: the reply-to ref”tell is fire-and-forget. For request/response, the convention is to
include a replyTo: ActorRef<ReplyType> field in the request message:
type Get = { readonly kind: 'get'; readonly replyTo: ActorRef<number> };type ReplyOK = { readonly kind: 'reply-ok'; readonly value: number };
class Counter extends Actor<Get> { private count = 42; override onReceive(command: Get): void { command.replyTo.tell(this.count); }}The caller provides a ref the actor sends the reply to. Three sources for that ref:
this.selfif the caller is another actor wanting the reply delivered back to itself.this.senderif the caller is acting on a previous message and wants the reply to go to whoever asked them. Note:this.senderisOption<ActorRef>— empty when the message came from outside the actor system.- A throwaway “ask” probe — what the Ask pattern
builds for you. Returns a
Promise<Reply>.
The reply-to-ref idiom is more verbose than a method call but earns
three things: requests and replies are decoupled in time (no await
chain), the reply can come from a different actor than the one
asked, and the same code works locally and cross-cluster.
Reply-type generics
Section titled “Reply-type generics”A common shape mistake: declaring replyTo: ActorRef<number> when the
actor sometimes replies with { kind: 'reply-ok' } and sometimes
{ kind: 'reply-error' }.
Use a discriminated union for the reply type, too:
type GetReply = | { readonly kind: 'reply-ok'; readonly value: number } | { readonly kind: 'reply-error'; readonly reason: string };
type Get = { readonly kind: 'get'; readonly replyTo: ActorRef<GetReply> };The caller’s onReceive can then match() on the reply’s kind to
distinguish the success and error paths the same way it dispatches
its own commands.
Command vs Event — the persistent-actor split
Section titled “Command vs Event — the persistent-actor split”For actors that persist events (PersistentActor), the framework splits commands (what to do) from events (what already happened):
import { match } from 'ts-pattern';
type DepositCommand = { readonly kind: 'deposit'; readonly amount: number };
type Deposited = { readonly kind: 'deposited'; readonly amount: number; readonly timestamp: number };
class Account extends PersistentActor<DepositCommand, Deposited, { balance: number }> { // command handler: validate + persist event async onCommand(state, command: DepositCommand): Promise<void> { await match(command) .with({ kind: 'deposit' }, (c) => this.onDeposit(c)) .exhaustive(); }
private async onDeposit(command: DepositCommand): Promise<void> { const event: Deposited = { kind: 'deposited', amount: command.amount, timestamp: Date.now() }; await this.persist(event, () => { /* post-persist side effects */ }); }
// event handler: state update only — runs on both write and recovery onEvent(state, event: Deposited): { balance: number } { // A fold that computes a value — arms stay inline. return match(event) .with({ kind: 'deposited' }, (e) => ({ balance: state.balance + e.amount })) .exhaustive(); }}The split matters because events are the durable record, replayed
on restart. Commands are transient (the request that produced the
event); events are eternal (the journal entry). Naming them
distinctly — past-tense for events (Deposited), imperative for
commands (DepositCommand) — keeps the conceptual line clear.
See PersistentActor for the full event-sourcing story.
Common pitfalls
Section titled “Common pitfalls”Where to next
Section titled “Where to next”- Pattern matching —
the
match().exhaustive()idiom used in every example on this page. - Ask pattern —
ref.ask(message, timeout)returns a Promise for the reply, avoiding the manual reply-to-ref dance. - Actor — the base class, the
onReceivesignature, the context references that produce reply refs. - Serialization — what happens to your messages when they cross a process / cluster boundary; how to register custom serializers when the JSON default doesn’t fit.
- PersistentActor — where the Command-vs-Event split becomes load-bearing.
