Skip to content

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 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:

  1. Compile-time exhaustiveness. match(cmd).with(...).exhaustive() (via ts-pattern) refuses to compile if you add a new kind and forget to handle it. The actor-ts codebase uses this pattern everywhere — see Pattern matching.
  2. Type narrowing inside handlers. Inside the .with({ kind: 'set' }, ...) arm, m.to is typed as number automatically — no casts, no as never.
  3. Wire-format stability. When a message crosses a process or cluster boundary, the serializer reads kind to 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.

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.

Messages should be immutable once enqueued. Two reasons:

  1. 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.
  2. 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 tell has 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 readonly properties (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.

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.self if the caller is another actor wanting the reply delivered back to itself.
  • this.sender if the caller is acting on a previous message and wants the reply to go to whoever asked them. Note: this.sender is Option<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.

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.

  • Pattern matching — the match().exhaustive() idiom used in every example on this page.
  • Ask patternask(ref, msg, timeout) returns a Promise for the reply, avoiding the manual reply-to-ref dance.
  • Actor — the base class, the onReceive signature, 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.