Messages
A message is any value sent to an actor. The type system enforces
which messages a given actor accepts (via the Actor<TMsg> 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 CounterCmd = | { readonly kind: 'inc' } | { readonly kind: 'dec' } | { readonly kind: 'set'; readonly to: number } | { readonly kind: 'get'; readonly replyTo: ActorRef<number> };This shape buys three things:
- Compile-time exhaustiveness.
match(cmd).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. When a message crosses a process or
cluster boundary, the serializer reads
kindto decide how to reconstitute the value. 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, Props, type ActorRef } from 'actor-ts';import { match } from 'ts-pattern';
type CounterCmd = | { readonly kind: 'inc' } | { readonly kind: 'dec' } | { readonly kind: 'get'; readonly replyTo: ActorRef<number> };
class Counter extends Actor<CounterCmd> { private count = 0;
override onReceive(cmd: CounterCmd): void { match(cmd) .with({ kind: 'inc' }, () => { this.count++; }) .with({ kind: 'dec' }, () => { this.count--; }) .with({ kind: 'get' }, (m) => m.replyTo.tell(this.count)) .exhaustive(); }}
const system = ActorSystem.create('counters');const counter = system.actorOf(Props.create(() => new Counter()));
counter.tell({ kind: 'inc' });counter.tell({ kind: 'inc' });counter.tell({ kind: 'inc' });// later, ask for the count via a reply-to ref — see Ask pattern.If you add a kind: 'reset' variant to CounterCmd 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(cmd: Get): void { cmd.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.
Cmd vs Event — the persistent-actor split
Section titled “Cmd vs Event — the persistent-actor split”For actors that persist events (PersistentActor), the framework splits commands (what to do) from events (what already happened):
type DepositCmd = { readonly kind: 'deposit'; readonly amount: number };
type Deposited = { readonly kind: 'deposited'; readonly amount: number; readonly ts: number };
class Account extends PersistentActor<DepositCmd, Deposited, { balance: number }> { // command handler: validate + persist event async onCommand(state, cmd: DepositCmd): Promise<void> { if (cmd.kind === 'deposit') { const event: Deposited = { kind: 'deposited', amount: cmd.amount, ts: 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 } { if (event.kind === 'deposited') return { balance: state.balance + event.amount }; return state; }}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 (DepositCmd) — 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 —
ask(ref, msg, 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 Cmd-vs-Event split becomes load-bearing.